diff --git a/AddMSPApp/huntress.app.xml b/AddMSPApp/huntress.app.xml index ad4f731a67ff8..7da78e3b95b1c 100644 --- a/AddMSPApp/huntress.app.xml +++ b/AddMSPApp/huntress.app.xml @@ -1,15 +1,15 @@ install.ps1 - 8859 + 17583 huntress.intunewin install.ps1 - 0wrFiLHex//63XQZEbX535qvhQE5+MiZmfPho1CMrT4= - UOlXFsrh+Pq6ZZNmg2+gzuTCSDAxQNUDVkc6oR5SVAY= - x0cPnMjK6AZARRPhOfC5pg== - z+N/v0mfq8T871kS07/QZ1Lgay2hRabSxwDWRKz3fG4= + m7JyMp35G+Kxi3slrppvQYJdgmgPJOkTHZGLHBCnFuY= + DxBAhgMG5nEedy933yQgx2mzTJbyku/ax8HEMYFRMQE= + hdOmI6VtHXWZq1Xr2icByw== + I6tLz8+o06fRsbbKpcE0J/PMA8/2l+B1jyHWarM8NUM= ProfileVersion1 - z8JuA/5iCrLM1cRkhL3di5eDysNsab62E812KGsrkbY= + 1bcXqEVxloLAnoEW6rHcFW/wJE4TVVfrtSLq9TGV4Ho= SHA256 \ No newline at end of file diff --git a/AddMSPApp/huntress.intunewin b/AddMSPApp/huntress.intunewin index 12c077f561450..7920439a1ef2a 100644 Binary files a/AddMSPApp/huntress.intunewin and b/AddMSPApp/huntress.intunewin differ diff --git a/Config/BaselineStandards/Entra (AAD) Standards/UsageLocation.json b/Config/BaselineStandards/Entra (AAD) Standards/UsageLocation.json new file mode 100644 index 0000000000000..babe250df816b --- /dev/null +++ b/Config/BaselineStandards/Entra (AAD) Standards/UsageLocation.json @@ -0,0 +1,65 @@ +{ + "name": "UsageLocation", + "label": "Usage location for all users", + "cat": "Entra (AAD) Standards", + "tag": [], + "impact": "Medium Impact", + "helpText": "Sets the Entra usage location (the two-letter country code that licensing and security tools such as Huntress ITDR depend on) on every member account. Optionally limit the sweep to members of named groups, skip members of named groups (users legitimately based in another country), or only fill in accounts that have no usage location yet.", + "executiveText": "Ensures every employee account records the country it is used from, which Microsoft requires before licences can be assigned and which security monitoring uses to recognise sign-ins from unexpected locations. Staff based in other countries are exempted through a group.", + "docsDescription": "Grades every member account against the configured usage location; guest accounts are ignored. Group names are resolved per tenant by display name and expanded to their transitive user members: include groups restrict the sweep to those members, exclude groups remove them from it. A configured group that does not exist in a tenant, or a failed membership lookup, reports No Data rather than sweeping the wrong accounts. With 'only set when blank' enabled, accounts that already carry any usage location are left as they are. Directory-synced accounts are included: usage location is cloud-managed and stays writable for them unless a custom sync rule maps it from on-premises.", + "impactColour": "warning", + "addedDate": "2026-09-16", + "powershellEquivalent": "Update-MgUser -UserId user@domain.com -UsageLocation 'US'", + "recommendedBy": [], + "requiredCapabilities": [], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "usageLocation": { + "type": "CountryCodeSelect", + "label": "Usage location", + "required": true + }, + "includeGroups": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "omitWhenBlank": true, + "label": "Only apply to members of these groups (display names; blank = all member accounts)" + }, + "excludeGroups": { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "omitWhenBlank": true, + "label": "Skip members of these groups (display names; users legitimately located elsewhere)" + }, + "onlyWhenBlank": { + "type": "switch", + "label": "Only set accounts that have no usage location (never overwrite an existing value)", + "default": false + } + }, + "expected": { + "offenders": [] + }, + "read": { + "cacheType": "Users" + }, + "prepare": "Get-CIPPBaselineUsageLocationState", + "remediate": { + "executor": "GraphBulkSweep", + "refreshCache": [ + "Users" + ], + "writes": [ + { + "method": "PATCH", + "uri": "users/%id%", + "body": { + "usageLocation": "%usageLocation%" + } + } + ] + } +} diff --git a/Config/BaselineStandards/Intune Standards/DevicePrepProfile.json b/Config/BaselineStandards/Intune Standards/DevicePrepProfile.json index df7e5f6b0bccf..a8efeb330d9e6 100644 --- a/Config/BaselineStandards/Intune Standards/DevicePrepProfile.json +++ b/Config/BaselineStandards/Intune Standards/DevicePrepProfile.json @@ -75,11 +75,11 @@ "omitWhenBlank": true, "options": [ { - "value": "0", + "value": "1", "label": "Standard user" }, { - "value": "1", + "value": "0", "label": "Administrator" } ] diff --git a/Config/BaselineStandards/SharePoint Standards/SPAnonymousLinkExpiration.json b/Config/BaselineStandards/SharePoint Standards/SPAnonymousLinkExpiration.json new file mode 100644 index 0000000000000..bf6cf955af25e --- /dev/null +++ b/Config/BaselineStandards/SharePoint Standards/SPAnonymousLinkExpiration.json @@ -0,0 +1,83 @@ +{ + "name": "SPAnonymousLinkExpiration", + "label": "Set expiration and permissions for Anyone (anonymous) sharing links", + "cat": "SharePoint Standards", + "tag": [ + "CISA (MS.SPO.3.1v1)", + "CISA (MS.SPO.3.2v1)" + ], + "impact": "Medium Impact", + "helpText": "Requires every Anyone sharing link in SharePoint and OneDrive to expire after the configured number of days. Optionally restricts the permission Anyone links can grant on files and folders (View only, or View and edit). By default Anyone links never expire.", + "executiveText": "Ensures that files and folders shared with anyone via a link stop being reachable after a set period, so a link forwarded or leaked months ago no longer grants access. Optionally limits such links to read-only, preventing anonymous recipients from changing shared content. Together these reduce the exposure created by convenience sharing without disabling it.", + "docsDescription": "Sets the tenant-wide expiration for Anyone (anonymous) sharing links in SharePoint and OneDrive (SharePoint admin center > Policies > Sharing > Choose expiration and permissions options for Anyone links). Links created after the policy is applied expire at the end of the configured period. A tenant that does not require expiration reads -1 for this property (the SharePoint default), which this standard grades as drift. The optional permission variable additionally grades and sets the maximum permission Anyone links may grant on files and folders (View, or View and edit); leave it blank to grade expiration only. These settings only take effect for tenants whose sharing level permits Anyone links (see the sharingCapability standard).", + "impactColour": "warning", + "addedDate": "2026-09-14", + "powershellEquivalent": "Set-SPOTenant -RequireAnonymousLinksExpireInDays 30 -FileAnonymousLinkType View -FolderAnonymousLinkType View", + "recommendedBy": [ + "CISA" + ], + "requiredCapabilities": [ + "SHAREPOINTWAC", + "SHAREPOINTSTANDARD", + "SHAREPOINTENTERPRISE", + "SHAREPOINTENTERPRISE_EDU", + "ONEDRIVE_BASIC", + "ONEDRIVE_ENTERPRISE" + ], + "secureScoreImpact": 0, + "compare": "subset", + "variables": { + "days": { + "type": "number", + "label": "Days until Anyone links expire (1-730, default 30)", + "default": 30, + "validators": { + "min": { + "value": 1, + "message": "Minimum is 1 day" + }, + "max": { + "value": 730, + "message": "SharePoint accepts at most 730 days" + } + } + }, + "linkPermission": { + "type": "autoComplete", + "label": "Anyone link permission for files and folders (optional - blank grades expiration only)", + "options": [ + { + "label": "View only", + "value": 1 + }, + { + "label": "View and edit", + "value": 2 + } + ], + "omitWhenBlank": true + } + }, + "expected": { + "RequireAnonymousLinksExpireInDays": "%days%", + "FileAnonymousLinkType": "%linkPermission%", + "FolderAnonymousLinkType": "%linkPermission%" + }, + "read": { + "cacheType": "SPOTenant" + }, + "writeTarget": "spoTenant", + "writeTargetProperties": { + "RequireAnonymousLinksExpireInDays": "%days%", + "FileAnonymousLinkType": "%linkPermission%", + "FolderAnonymousLinkType": "%linkPermission%" + }, + "remediate": { + "executor": "SPOTenant", + "properties": { + "RequireAnonymousLinksExpireInDays": "%days%", + "FileAnonymousLinkType": "%linkPermission%", + "FolderAnonymousLinkType": "%linkPermission%" + } + } +} diff --git a/Config/BecHeuristics.json b/Config/BecHeuristics.json new file mode 100644 index 0000000000000..c2098ccde98f8 --- /dev/null +++ b/Config/BecHeuristics.json @@ -0,0 +1,243 @@ +{ + "window": { + "days": 7 + }, + "caps": { + "auditLogPages": 10, + "mailActivityPages": 10 + }, + "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, + "AttackerIPs": 4, + "AttackerMailAccess": 3, + "AttackerFileAccess": 2, + "AttackerForms": 3, + "DelegatedMailboxAttackerAccess": 3, + "OtherAccountsReached": 3 + } + }, + "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)" + }, + "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)" + }, + "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", + "Invite external user", + "Add user", + "Add member to role", + "Add eligible member to role", + "Disable Strong Authentication", + "Update user attributes (StrongAuthentication)" + ] + }, + "mailActivity": { + "userOperations": [ + "MailItemsAccessed", + "HardDelete", + "SoftDelete", + "MoveToDeletedItems", + "Send", + "AttachmentAccess", + "Move", + "UpdateFolderPermissions", + "UpdateCalendarDelegation", + "SearchQueryInitiatedExchange", + "Set-Mailbox" + ], + "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" + ] + }, + "baseline": { + "days": 30, + "colleagueSample": 8 + }, + "ipVerdict": { + "thresholds": { + "likelyAttacker": 6, + "suspicious": 3, + "likelyUser": -3 + }, + "minBaselineSignIns": 10, + "regularShare": 0.05, + "regularDays": 3, + "knownAsnShare": 0.2, + "knownLocationShare": 0.1, + "serviceAsnRegex": "(?i)microsoft", + "scriptedUserAgentRegex": "(?i)(python|axios|curl|okhttp|go-http-client|node-fetch|powershell|libwww|java/|httpclient|postman)", + "weights": { + "flaggedAction": 4, + "flaggedActionExtraKind": 1, + "hostingOrProxy": 3, + "foreign": 2, + "newToUser": 2, + "newNetwork": 1, + "newLocation": 1, + "riskySignIn": 3, + "riskySignInLow": 1, + "scriptedClient": 3, + "widerAttack": 2, + "sharedSession": 3, + "blockListHint": 2, + "baselineRegular": -4, + "baselineSeen": -2, + "knownNetwork": -1, + "knownLocation": -1, + "compliantDevice": -2, + "colleagues": -2, + "trustedNamedLocation": -4, + "technicianAddress": -4, + "allowListHint": -1, + "onlyFailedSignIns": -3 + }, + "serviceAppIds": { + "2832473f-ec63-45fb-976f-5d45a7d4bb91": "Partner Customer Delegated Administration (GDAP)" + } + }, + "attackerActivity": { + "fileOperations": [ + "FileAccessed", + "FileDownloaded", + "FileSyncDownloadedFull", + "FilePreviewed", + "FileUploaded", + "FileModified", + "FileDeleted", + "FileRecycled", + "FileMoved", + "FileCopied", + "FileRenamed", + "SearchQueryPerformed" + ], + "linkUsageOperations": [ + "AnonymousLinkUsed", + "CompanyLinkUsed", + "SecureLinkUsed", + "SharingInvitationAccepted" + ], + "formsResponseOperations": [ + "CreateResponse", + "SubmitResponse", + "ViewRuntimeForm", + "UpdatePhishingStatus" + ], + "formsAttackerOperations": [ + "CreateForm", + "EditForm", + "ShareForm", + "SendForm", + "AllowShareFormForCopy", + "AddFormCoauthor", + "ConnectToExcelWorkbook", + "ExportForm", + "ViewResponses", + "GetSummaryLink" + ], + "subjectLookbackDays": 90 + } +} diff --git a/Config/CIPPDBCacheTypes.json b/Config/CIPPDBCacheTypes.json index 08da042719e89..8802058bc35a0 100644 --- a/Config/CIPPDBCacheTypes.json +++ b/Config/CIPPDBCacheTypes.json @@ -149,6 +149,16 @@ "friendlyName": "License Overview", "description": "License usage overview" }, + { + "type": "ActiveUserDetail", + "friendlyName": "Active User Detail", + "description": "Per-user last-activity dates for Exchange, OneDrive, SharePoint, Teams and Viva Engage over 90 days" + }, + { + "type": "M365AppUserDetail", + "friendlyName": "Microsoft 365 App Usage Detail", + "description": "Per-user Office app usage by platform (Windows, Mac, mobile, web) over 90 days" + }, { "type": "MFAState", "friendlyName": "MFA State", @@ -251,8 +261,8 @@ }, { "type": "ExoProtectionAlert", - "friendlyName": "Exchange Protection Alerts", - "description": "Microsoft 365 protection alert policies (Security & Compliance endpoint)" + "friendlyName": "Exchange Protection Alert Policies", + "description": "Microsoft 365 protection alert policy definitions (Security & Compliance endpoint) - the configured alert rules, NOT alerts that have fired" }, { "type": "Mailboxes", @@ -274,6 +284,11 @@ "friendlyName": "Mailbox Usage", "description": "Exchange Online mailbox usage statistics" }, + { + "type": "MailTrafficSummary", + "friendlyName": "Mail Traffic Summary", + "description": "Top mail senders and recipients by message volume over the last 30 days" + }, { "type": "OneDriveSiteListing", "collectedBy": "OneDriveUsage", @@ -301,6 +316,11 @@ "friendlyName": "SharePoint Site Usage", "description": "SharePoint site usage statistics" }, + { + "type": "SharePointUsageReport", + "friendlyName": "SharePoint Usage Report (Tenant-level)", + "description": "Aggregated SharePoint storage usage across all sites in the tenant" + }, { "type": "SiteActivity", "friendlyName": "Site Activity", diff --git a/Config/CIPPDefaultTable.BPATemplate.json b/Config/CIPPDefaultTable.BPATemplate.json deleted file mode 100644 index 735012b4bd777..0000000000000 --- a/Config/CIPPDefaultTable.BPATemplate.json +++ /dev/null @@ -1,194 +0,0 @@ -{ - "name": "CIPP Best Practices v1.5 - Table view", - "style": "Table", - "Fields": [ - { - "name": "PasswordNeverExpires", - "API": "Graph", - "URL": "https://graph.microsoft.com/beta/domains", - "ExtractFields": ["passwordValidityPeriodInDays"], - "where": "$_.passwordValidityPeriodInDays -eq 2147483647", - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Password Never Expires", - "value": "PasswordNeverExpires", - "formatter": "bool" - } - ] - }, - { - "name": "OAuthAppConsent", - "API": "Graph", - "URL": "https://graph.microsoft.com/v1.0/policies/authorizationPolicy?$select=defaultUserRolePermissions", - "ExtractFields": ["defaultuserrolepermissions"], - "where": "'ManagePermissionGrantsForSelf.microsoft-user-default-legacy' -notin $_.defaultuserrolepermissions.permissionGrantPoliciesAssigned", - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "OAuth App Consent", - "value": "OAuthAppConsent", - "formatter": "bool" - } - ] - }, - { - "name": "UnifiedAuditLog", - "API": "Exchange", - "Command": "Get-AdminAuditLogConfig", - "ExtractFields": ["UnifiedAuditLogIngestionEnabled"], - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Unified Audit Log", - "value": "UnifiedAuditLog", - "formatter": "bool" - } - ] - }, - { - "name": "MFANudgeState", - "API": "Graph", - "URL": "https://graph.microsoft.com/beta/policies/authenticationMethodsPolicy", - "ExtractFields": ["registrationEnforcement"], - "StoreAs": "bool", - "where": "$_.registrationEnforcement.authenticationMethodsRegistrationCampaign.state -eq 'Enabled'", - "FrontendFields": [ - { - "name": "MFA Registration Campaign Enabled", - "value": "MFANudgeState", - "formatter": "bool" - } - ] - }, - { - "name": "TAPEnabled", - "API": "Graph", - "URL": "https://graph.microsoft.com/beta/policies/authenticationmethodspolicy/authenticationMethodConfigurations/TemporaryAccessPass", - "ExtractFields": ["State"], - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Temporary Access Pass Enabled", - "value": "TAPEnabled", - "formatter": "bool" - } - ] - }, - { - "name": "SecureDefaultState", - "API": "Graph", - "URL": "https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy", - "ExtractFields": ["IsEnabled"], - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Secure Defaults State Enabled", - "value": "SecureDefaultState", - "formatter": "warnBool" - } - ] - }, - { - "name": "AnonymousPrivacyReports", - "API": "Graph", - "URL": "https://graph.microsoft.com/beta/admin/reportSettings", - "ExtractFields": ["displayConcealedNames"], - "StoreAs": "bool", - "where": "$_.displayConcealedNames -eq $false", - "FrontendFields": [ - { - "name": "Anonymous Privacy Reports", - "value": "AnonymousPrivacyReports", - "formatter": "reverseBool" - } - ] - }, - { - "name": "MessageCopyforSentAsDisabled", - "API": "Exchange", - "Command": "Get-Mailbox", - "Parameters": { - "RecipientTypeDetails": ["SharedMailbox", "UserMailbox"] - }, - "where": "$_.MessageCopyForSentAsEnabled -eq $false", - "ExtractFields": ["userprincipalname", "messageCopyForSentAsEnabled"], - "StoreAs": "JSON", - "FrontendFields": [ - { - "name": "Message Copy for Sent-As Disabled", - "formatter": "table", - "value": "MessageCopyforSentAsDisabled" - } - ] - }, - { - "name": "SharedMailboxeswithenabledusers", - "API": "Exchange", - "Command": "Get-Mailbox", - "Parameters": { - "RecipientTypeDetails": "SharedMailbox" - }, - "where": "$_.accountDisabled -eq $false", - "ExtractFields": ["userprincipalname", "accountDisabled"], - "StoreAs": "JSON", - "FrontendFields": [ - { - "name": "Shared Mailboxes with enabled users", - "formatter": "table", - "value": "SharedMailboxeswithenabledusers" - } - ] - }, - { - "name": "Unusedlicenses", - "API": "CIPPFunction", - "Command": "Get-CIPPLicenseOverview", - "ExtractFields": [ - "License", - "TotalLicenses", - "availableUnits", - "CountUsed" - ], - "StoreAs": "JSON", - "where": "$_.availableUnits -gt 0", - "FrontendFields": [ - { - "name": "Unused licenses", - "formatter": "table", - "value": "Unusedlicenses" - } - ] - }, - { - "name": "CurrentSecureScore", - "API": "Graph", - "URL": "https://graph.microsoft.com/beta/security/secureScores?$top=1", - "Parameters": { - "Nopagination": true - }, - "ExtractFields": ["currentScore", "maxScore", "averageComparativeScores"], - "StoreAs": "JSON", - "FrontendFields": [ - { - "name": "Current Secure Score", - "value": "CurrentSecureScore.currentScore / CurrentSecureScore.maxScore * 100", - "formatter": "math", - "showAs": "percentage" - }, - { - "name": "Average Comparative Score (All Tenants)", - "value": "CurrentSecureScore.averageComparativeScores[0].averageScore / CurrentSecureScore.maxScore * 100", - "formatter": "math", - "showAs": "percentage" - }, - { - "name": "Average Comparative Score (Similiar Size Tenants)", - "value": "CurrentSecureScore.averageComparativeScores[1].averageScore / CurrentSecureScore.maxScore * 100", - "formatter": "math", - "showAs": "percentage" - } - ] - } - ] -} diff --git a/Config/CIPPDefaultTenantPage.BPATemplate.json b/Config/CIPPDefaultTenantPage.BPATemplate.json deleted file mode 100644 index aa706369f1192..0000000000000 --- a/Config/CIPPDefaultTenantPage.BPATemplate.json +++ /dev/null @@ -1,155 +0,0 @@ -{ - "name": "CIPP Best Practices v1.0 - Tenant view", - "style": "Tenant", - "Fields": [ - { - "name": "PasswordNeverExpires", - "UseExistingInfo": true, - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Password Never Expires", - "value": "PasswordNeverExpires", - "formatter": "bool", - "desc": "This setting shows if your environment has enabled the password never expires setting. This setting is expected to be set to 'No'" - } - ] - }, - { - "name": "OAuthAppConsent", - "UseExistingInfo": true, - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "OAuth App Consent", - "value": "OAuthAppConsent", - "formatter": "bool", - "desc": "This setting shows if your environment has enabled OAuth App Consent. This setting is expected to be set to 'Yes'" - } - ] - }, - { - "name": "UnifiedAuditLog", - "UseExistingInfo": true, - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Unified Audit Log", - "value": "UnifiedAuditLog", - "formatter": "bool", - "desc": "This setting shows if your environment has enabled the unified audit log. This setting is expected to be set to 'Yes'" - } - ] - }, - { - "name": "MFANudgeState", - "UseExistingInfo": true, - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "MFA Registration Campaign Enabled", - "value": "MFANudgeState", - "formatter": "bool", - "desc": "This setting shows if your environment has enabled the MFA registration campaign, also known as the MFA Nudge. This setting is recommended to be set to 'Yes'" - } - ] - }, - { - "name": "TAPEnabled", - "UseExistingInfo": true, - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Temporary Access Pass Enabled", - "value": "TAPEnabled", - "formatter": "bool", - "desc": "This setting shows if your environment has enabled the temporary access pass feature." - } - ] - }, - { - "name": "SecureDefaultState", - "UseExistingInfo": true, - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Secure Defaults State Enabled", - "value": "SecureDefaultState", - "formatter": "warnBool", - "desc": "This setting shows if your environment has enabled the secure defaults state. If you are using Conditional Access this setting may be set to `No`" - } - ] - }, - { - "name": "AnonymousPrivacyReports", - "UseExistingInfo": true, - "StoreAs": "bool", - "FrontendFields": [ - { - "name": "Anonymous Privacy Reports", - "value": "AnonymousPrivacyReports", - "formatter": "reverseBool", - "desc": "This setting shows if your environment has enabled the anonymous privacy reports, these will need to be disabled to be able to view mailboxes and onedrive reports" - } - ] - }, - { - "name": "MessageCopyforSentAsDisabled", - "UseExistingInfo": true, - "StoreAs": "JSON", - "FrontendFields": [ - { - "name": "Message Copy for Sent-As Disabled", - "formatter": "table", - "value": "MessageCopyforSentAsDisabled", - "desc": "These are the mailboxes that have the MessageCopyForSentAsDisabled setting enabled." - } - ] - }, - { - "name": "SharedMailboxeswithenabledusers", - "UseExistingInfo": true, - "StoreAs": "JSON", - "FrontendFields": [ - { - "name": "Shared Mailboxes with enabled users", - "formatter": "table", - "value": "SharedMailboxeswithenabledusers", - "desc": "These are the shared mailboxes that have enabled users." - } - ] - }, - { - "name": "Unusedlicenses", - "UseExistingInfo": true, - "StoreAs": "JSON", - "FrontendFields": [ - { - "name": "Unused licenses", - "formatter": "table", - "value": "Unusedlicenses", - "desc": "These are the licenses that are not assigned to an user, but have been purchased." - } - ] - }, - { - "name": "CurrentSecureScore", - "UseExistingInfo": true, - "StoreAs": "JSON", - "FrontendFields": [ - { - "name": "Current Secure Score", - "value": "CurrentSecureScore.currentScore", - "desc": "The current Secure Score for this tenant. This is the sum of all the individual controls that have been implemented.", - "formatter": "number" - }, - { - "name": "Max Secure Score", - "value": "CurrentSecureScore.maxScore", - "desc": "The maximum Secure Score for this tenant. This is the sum of all the individual controls that can be implemented.", - "formatter": "number" - } - ] - } - ] -} diff --git a/Config/CIPPTenantFeatures.BPATemplate.json b/Config/CIPPTenantFeatures.BPATemplate.json deleted file mode 100644 index 19cc09628a4fd..0000000000000 --- a/Config/CIPPTenantFeatures.BPATemplate.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "CIPP Tenant Feature Licensing", - "style": "Table", - "Fields": [ - { - "name": "AssignedPlans", - "UseExistingInfo": false, - "ExtractFields": ["AADPremiumService", "exchange", "SharePoint"], - "FrontendFields": [ - { - "name": "Entra ID Premium", - "value": "AssignedPlans.AADPremiumService", - "formatter": "bool" - }, - { - "name": "Exchange", - "value": "AssignedPlans.exchange", - "formatter": "bool" - }, - { - "name": "SharePoint", - "value": "AssignedPlans.SharePoint", - "formatter": "bool" - } - ], - "desc": "Entra ID Premium Status", - "StoreAs": "JSON", - "API": "CIPPFunction", - "Command": "Get-CIPPTenantCapabilities", - "Parameters": {} - } - ] -} diff --git a/Config/CIPPTimers.json b/Config/CIPPTimers.json index 48bbfec9312bb..ae05799079f0c 100644 --- a/Config/CIPPTimers.json +++ b/Config/CIPPTimers.json @@ -157,15 +157,6 @@ "TZOffset": true, "RunOnProcessor": true }, - { - "Id": "80070b4f-95ed-4e5f-be4c-9e339306d4aa", - "Command": "Start-BPAOrchestrator", - "Description": "Orchestrator to process BPA reports", - "Cron": "0 0 3 * * *", - "Priority": 10, - "TZOffset": true, - "RunOnProcessor": true - }, { "Id": "54c39540-fe91-4795-8613-ac4295751a51", "Command": "Start-ExtensionOrchestrator", diff --git a/Config/CommunityRepos.json b/Config/CommunityRepos.json index 126df73ba79b2..a62b763d9b81c 100644 --- a/Config/CommunityRepos.json +++ b/Config/CommunityRepos.json @@ -38,7 +38,6 @@ "GroupTemplate", "AppApprovalTemplate", "ReportBuilderTemplate", - "BPATemplate", "TransportTemplate", "ExConnectorTemplate", "AppTemplate", diff --git a/Config/CyberEssentials.BPATemplate.json b/Config/CyberEssentials.BPATemplate.json deleted file mode 100644 index af2a2476ce0ef..0000000000000 --- a/Config/CyberEssentials.BPATemplate.json +++ /dev/null @@ -1,101 +0,0 @@ -{ - "name": "CIPP Cyber Essentials Helper - Tenant view", - "style": "Tenant", - "Fields": [ - { - "name": "deviceregister", - "UseExistingInfo": false, - "FrontendFields": [ - { - "name": "Device Register", - "value": "deviceregister", - "desc": "These are all devices found in M365 to add to your CE Device Register.", - "formatter": "table" - } - ], - "StoreAs": "JSON", - "API": "Graph", - "ExtractFields": [ - "deviceName", - "lastSyncDateTime", - "osVersion", - "userPrincipalName", - "complianceState" - ], - "URL": "https://graph.microsoft.com/beta/deviceManagement/managedDevices" - }, - { - "name": "adminsTable", - "UseExistingInfo": false, - "FrontendFields": [ - { - "name": "Admins Table", - "value": "adminsTable", - "desc": "The list of admin accounts in your M365 environment. These must all be named and attached to actual users.", - "formatter": "table" - } - ], - "StoreAs": "JSON", - "API": "Graph", - "ExtractFields": ["displayName", "userPrincipalName"], - "URL": "https://graph.microsoft.com/beta/directoryRoles/roleTemplateId=62e90394-69f5-4237-9190-012177145e10/members" - }, - { - "name": "windowsProtectionState", - "UseExistingInfo": false, - "FrontendFields": [ - { - "name": "Defender List", - "value": "windowsProtectionState", - "formatter": "table", - "desc": "List of Defender protected workstations. Add this to your Malware Protection Audit List" - } - ], - "StoreAs": "JSON", - "API": "Graph", - "ExtractFields": ["windowsProtectionState"], - "Parameters": {}, - "URL": "https://graph.microsoft.com/beta/deviceManagement/managedDevices?$expand=windowsProtectionState" - }, - { - "name": "detectedApps", - "UseExistingInfo": false, - "FrontendFields": [ - { - "name": "Detected Apps", - "value": "detectedApps", - "desc": "All Detected Software and their version. Add this to your software audit list", - "formatter": "table" - } - ], - "StoreAs": "JSON", - "API": "Graph", - "ExtractFields": ["displayName", "version", "platform", "publisher"], - "URL": "https://graph.microsoft.com/beta/deviceManagement/detectedApps" - }, - { - "name": "userRegistrationDetails", - "UseExistingInfo": false, - "FrontendFields": [ - { - "name": "User Registration Details", - "desc": "All MFA settings (Requires a P1 Subscription)", - "value": "userRegistrationDetails", - "formatter": "table" - } - ], - "StoreAs": "JSON", - "API": "Graph", - "ExtractFields": [ - "userDisplayName", - "isAdmin", - "isMFARegistered", - "defaultMFAMethod" - ], - "URL": "https://graph.microsoft.com/beta/reports/authenticationMethods/userRegistrationDetails", - "Parameters": { - "asApp": "True" - } - } - ] -} diff --git a/Config/DocsPublishedPages.txt b/Config/DocsPublishedPages.txt index 1e21236768e88..7f31da7270420 100644 --- a/Config/DocsPublishedPages.txt +++ b/Config/DocsPublishedPages.txt @@ -1,7 +1,7 @@ # Slugs published on docs.cipp.app, snapshotted from llms.txt. # Generated by build/tools/Update-DocsPublishedPages.ps1 - do not hand-edit. # Read by Get-CippDocsPublishedSet so the docs search index never emits a URL that 404s. -# 426 pages. +# 464 pages. api-documentation/endpoints api-documentation/setup-and-authentication demos/showcases @@ -36,6 +36,7 @@ setup/installation/owntenant setup/maintaining-cipp setup/maintaining-cipp/migrating-to-hosted-cipp setup/maintaining-cipp/migrating-to-the-latest-version-of-cipp +setup/maintaining-cipp/migrating-to-the-new-infrastructure setup/maintaining-cipp/recommended-roles setup/maintaining-cipp/updating setup/resources @@ -68,8 +69,10 @@ user-documentation/cipp/advanced/authentication/cipp-users user-documentation/cipp/advanced/authentication/sam-app-permissions user-documentation/cipp/advanced/authentication/sam-app-roles user-documentation/cipp/advanced/authentication/sso +user-documentation/cipp/advanced/cippdb-cache user-documentation/cipp/advanced/container-management user-documentation/cipp/advanced/container-management/custom-domains +user-documentation/cipp/advanced/container-management/diagnostics user-documentation/cipp/advanced/container-management/logs user-documentation/cipp/advanced/container-management/status user-documentation/cipp/advanced/container-management/worker-health @@ -107,6 +110,7 @@ user-documentation/cipp/sam-setup-wizard user-documentation/cipp/settings user-documentation/cipp/settings/backend user-documentation/cipp/settings/backup +user-documentation/cipp/settings/bec-remediation user-documentation/cipp/settings/branding user-documentation/cipp/settings/features user-documentation/cipp/settings/licenses @@ -147,6 +151,9 @@ user-documentation/email/administration/hve-accounts user-documentation/email/administration/mailbox-rules user-documentation/email/administration/mailboxes user-documentation/email/administration/quarantine +user-documentation/email/administration/quarantine/files +user-documentation/email/administration/quarantine/teams +user-documentation/email/administration/quarantine/user-reported user-documentation/email/administration/restricted-users user-documentation/email/administration/tenant-allow-block-list-templates user-documentation/email/administration/tenant-allow-block-lists @@ -162,6 +169,7 @@ user-documentation/email/reports/activesync-devices user-documentation/email/reports/antiphishing-filters user-documentation/email/reports/calendar-permissions user-documentation/email/reports/global-address-list +user-documentation/email/reports/mail-flow-statistics user-documentation/email/reports/mailbox-activity user-documentation/email/reports/mailbox-cas-settings user-documentation/email/reports/mailbox-forwarding @@ -226,6 +234,8 @@ user-documentation/endpoint/reports/detected-apps user-documentation/endpoint/reports/work-from-anywhere user-documentation/identity user-documentation/identity/administration +user-documentation/identity/administration/bec +user-documentation/identity/administration/bec/case user-documentation/identity/administration/deleted-items user-documentation/identity/administration/devices user-documentation/identity/administration/group-templates @@ -236,25 +246,37 @@ user-documentation/identity/administration/groups user-documentation/identity/administration/groups/add user-documentation/identity/administration/groups/edit user-documentation/identity/administration/groups/group +user-documentation/identity/administration/guest-users user-documentation/identity/administration/jit-admin user-documentation/identity/administration/jit-admin-templates user-documentation/identity/administration/jit-admin-templates/add user-documentation/identity/administration/jit-admin-templates/edit user-documentation/identity/administration/jit-admin/add +user-documentation/identity/administration/jit-role-templates +user-documentation/identity/administration/jit-role-templates/add +user-documentation/identity/administration/jit-role-templates/edit user-documentation/identity/administration/offboarding-wizard user-documentation/identity/administration/risky-users +user-documentation/identity/administration/roles +user-documentation/identity/administration/roles/role +user-documentation/identity/administration/roles/role/audit +user-documentation/identity/administration/roles/role/pim +user-documentation/identity/administration/roles/templates +user-documentation/identity/administration/roles/templates/add +user-documentation/identity/administration/roles/templates/edit user-documentation/identity/administration/user-defaults user-documentation/identity/administration/users user-documentation/identity/administration/users/patch-wizard user-documentation/identity/administration/users/user -user-documentation/identity/administration/users/user/bec user-documentation/identity/administration/users/user/conditional-access user-documentation/identity/administration/users/user/edit user-documentation/identity/administration/users/user/exchange +user-documentation/identity/administration/users/user/onedrive-shortcuts user-documentation/identity/administration/vacation-mode user-documentation/identity/administration/vacation-mode/add-vacation-schedule user-documentation/identity/reports user-documentation/identity/reports/azure-ad-connect-report +user-documentation/identity/reports/group-usage user-documentation/identity/reports/inactive-users-report user-documentation/identity/reports/mfa-report user-documentation/identity/reports/risk-detections @@ -293,16 +315,19 @@ user-documentation/security/safelinks/safelinks/add user-documentation/security/safelinks/safelinks/edit user-documentation/shared-features user-documentation/shared-features/breadcrumb-navigation +user-documentation/shared-features/entity-switcher user-documentation/shared-features/get-help user-documentation/shared-features/global-page-icon user-documentation/shared-features/keyboard-shortcuts user-documentation/shared-features/menu-bar user-documentation/shared-features/menu-bar/bookmarks user-documentation/shared-features/menu-bar/display-mode +user-documentation/shared-features/menu-bar/refresh-my-access user-documentation/shared-features/menu-bar/search user-documentation/shared-features/menu-bar/tenant-select user-documentation/shared-features/menu-bar/universal-search user-documentation/shared-features/menu-bar/user-settings +user-documentation/shared-features/mobile-layout user-documentation/shared-features/release-notes-notification user-documentation/shared-features/speed-dial user-documentation/shared-features/table-features @@ -318,6 +343,7 @@ user-documentation/teams-share/sharepoint-templates/add user-documentation/teams-share/sharepoint/add-site user-documentation/teams-share/sharepoint/bulk-add-site user-documentation/teams-share/sharing-report +user-documentation/teams-share/storage-report user-documentation/teams-share/teams user-documentation/teams-share/teams/business-voice user-documentation/teams-share/teams/list-team @@ -327,6 +353,8 @@ user-documentation/tenant user-documentation/tenant/administration user-documentation/tenant/administration/alert-configuration user-documentation/tenant/administration/alert-configuration/alert +user-documentation/tenant/administration/alert-configuration/alert-history +user-documentation/tenant/administration/alert-configuration/rogue-apps user-documentation/tenant/administration/alert-configuration/snoozed-alerts user-documentation/tenant/administration/app-consent-requests user-documentation/tenant/administration/applications @@ -354,6 +382,10 @@ user-documentation/tenant/administration/tenants user-documentation/tenant/administration/tenants/global-variables user-documentation/tenant/administration/tenants/groups user-documentation/tenant/administration/tenants/groups/edit +user-documentation/tenant/baselines +user-documentation/tenant/baselines/alignment +user-documentation/tenant/baselines/template +user-documentation/tenant/baselines/templates user-documentation/tenant/conditional user-documentation/tenant/conditional/list-named-locations user-documentation/tenant/conditional/list-named-locations/add @@ -374,6 +406,7 @@ user-documentation/tenant/gdap-management/relationships/relationship/mappings user-documentation/tenant/gdap-management/role-templates user-documentation/tenant/gdap-management/role-templates/add user-documentation/tenant/gdap-management/role-templates/edit +user-documentation/tenant/gdap-management/role-templates/mappings user-documentation/tenant/gdap-management/role-templates/mappings/add user-documentation/tenant/gdap-management/roles user-documentation/tenant/manage @@ -390,13 +423,15 @@ user-documentation/tenant/reports/graph-office-reports user-documentation/tenant/reports/list-csp-licenses user-documentation/tenant/reports/list-csp-licenses/add-subscription user-documentation/tenant/reports/list-licenses +user-documentation/tenant/reports/list-licenses/optimization +user-documentation/tenant/reports/list-licenses/pricing +user-documentation/tenant/security-simulator +user-documentation/tenant/security-simulator/conditional-access +user-documentation/tenant/security-simulator/situations user-documentation/tenant/standards user-documentation/tenant/standards/alignment user-documentation/tenant/standards/alignment/templates user-documentation/tenant/standards/alignment/templates/available-standards -user-documentation/tenant/standards/bpa-report -user-documentation/tenant/standards/bpa-report/best-practice-templates -user-documentation/tenant/standards/bpa-report/builder user-documentation/tenant/standards/domains-analyser user-documentation/tenant/standards/domains-analyser/domain-analyser-updates-and-data-refreshing user-documentation/tenant/standards/template @@ -410,7 +445,9 @@ user-documentation/tools/dark-web-tools user-documentation/tools/dark-web-tools/breach-lookup user-documentation/tools/dark-web-tools/tenant-breach-lookup user-documentation/tools/email-tools +user-documentation/tools/email-tools/historical-search user-documentation/tools/email-tools/mailbox-restores +user-documentation/tools/email-tools/message-encryption user-documentation/tools/email-tools/message-trace user-documentation/tools/email-tools/message-viewer user-documentation/tools/intune-tools @@ -421,6 +458,7 @@ user-documentation/tools/report-builder/generated user-documentation/tools/report-builder/templates user-documentation/tools/scheduler user-documentation/tools/scheduler/task +user-documentation/tools/template-packages user-documentation/tools/templatelib user-documentation/tools/tenant-tools user-documentation/tools/tenant-tools/appapproval diff --git a/Config/DocsSynonyms.json b/Config/DocsSynonyms.json index 91affbe50f91e..5dc23f733405d 100644 --- a/Config/DocsSynonyms.json +++ b/Config/DocsSynonyms.json @@ -9,7 +9,6 @@ "dap": ["delegated admin privileges"], "sam": ["secure application model", "service account", "application registration"], "bec": ["business email compromise", "compromise remediation", "indicators of compromise"], - "bpa": ["best practice analyser", "report builder"], "cis": ["compliance benchmark test"], "spf": ["domain analyser", "email authentication", "dns record"], "dkim": ["domain analyser", "email authentication", "dns record"], diff --git a/Config/FeatureFlags.json b/Config/FeatureFlags.json index 75a740f482d1d..6cf9d0945b5dd 100644 --- a/Config/FeatureFlags.json +++ b/Config/FeatureFlags.json @@ -1,25 +1,4 @@ [ - { - "Id": "BestPracticeAnalyser", - "Name": "Best Practice Analyser", - "Description": "The Best Practice Analyser has been deprecated and will be removed in a future release.", - "Enabled": false, - "AllowUserToggle": true, - "Timers": ["80070b4f-95ed-4e5f-be4c-9e339306d4aa"], - "Endpoints": [ - "BestPracticeAnalyser_List", - "ExecBPA", - "ListBPA", - "ListBPATemplates", - "RemoveBPATemplate" - ], - "Pages": [ - "/tenant/standards/bpa-report", - "/tenant/standards/bpa-report/builder", - "/tenant/standards/bpa-report/view" - ], - "Hidden": false - }, { "Id": "SuperAdminNG", "Name": "Super Admin", @@ -91,7 +70,7 @@ { "Id": "Baselines", "Name": "Baselines", - "Description": "This is pre-alpha do not enable in production. The drift-first baseline engine that replaces classic Standards and Drift. Enables the scheduled baseline run and the Baselines menu, hides the classic Standards and Drift pages, and skips the scheduled classic Standards and Drift runs - the two systems never run side by side.", + "Description": "This is currently in public beta. The drift-first baseline engine that replaces classic Standards and Drift. Enables the scheduled baseline run and the Baselines menu, hides the classic Standards and Drift pages, and skips the scheduled classic Standards and Drift runs - the two systems never run side by side.", "Enabled": false, "AllowUserToggle": true, "Timers": ["9f2c7b1e-4a6d-4c3f-8b9a-5e1d2f7c0a44"], @@ -105,6 +84,18 @@ ], "Hidden": false }, + { + "Id": "SecuritySimulations", + "Name": "Security Simulations", + "Description": "This is currently in public beta. Play security events (a stolen token, an admin on an unmanaged device, a mass deletion, an anonymous sharing link) against a tenant to see what an attacker would experience today and which standards close each gap, plus a live Conditional Access gap analysis. Read-only: nothing is changed in the tenant.", + "Enabled": false, + "AllowUserToggle": true, + "Timers": [], + "Endpoints": [], + "Pages": ["/tenant/security-simulator"], + "HidesPages": [], + "Hidden": false + }, { "Id": "CertificateAuthentication", "Name": "Certificate Authentication", diff --git a/Config/GraphEndpointBlocklist.json b/Config/GraphEndpointBlocklist.json new file mode 100644 index 0000000000000..07b3f3062de5b --- /dev/null +++ b/Config/GraphEndpointBlocklist.json @@ -0,0 +1,136 @@ +{ + "version": 1, + "description": "Graph paths treated as customer-content for Test-CIPPGraphEndpointBlocked. Case-insensitive regexes against the percent-decoded path (host/version stripped) and against $expand values. Segments end at /, (, ;, : or end of path so OData key syntax like messages('id') is covered.", + "blockedEndpoints": [ + { + "id": "drive-trees", + "pattern": "(^|/)drives?(?=[/(;:]|$)", + "reason": "driveItem GETs/children return @microsoft.graph.downloadUrl" + }, + { + "id": "drive-item", + "pattern": "(^|/)driveItem(?=[/(;:]|$)", + "reason": "List item driveItem returns @microsoft.graph.downloadUrl" + }, + { + "id": "shares", + "pattern": "(^|/)(?", + "MailboxOwner": "sample.user@example.com", + "IP": "198.51.100.23", + "IPVerdict": "LikelyAttacker" + }, + { + "When": "2026-08-04T02:20:00Z", + "Operation": "Send", + "Folder": "Sent Items", + "Subject": "RE: Invoice 4471", + "InternetMessageId": "", + "MailboxOwner": "sample.user@example.com", + "IP": "198.51.100.23", + "IPVerdict": "LikelyAttacker" + } + ], + "AttackerFileActivity": [ + { + "When": "2026-08-04T02:30:00Z", + "Operation": "FileDownloaded", + "File": "Payroll 2026.xlsx", + "Url": "https://example.sharepoint.com/sites/finance/Payroll 2026.xlsx", + "Site": "https://example.sharepoint.com/sites/finance", + "IP": "198.51.100.23", + "IPVerdict": "LikelyAttacker" + } + ], + "FormsActivity": [ + { + "When": "2026-08-04T03:00:00Z", + "Operation": "CreateForm", + "FormName": "Microsoft 365 account verification", + "FormId": "form-1", + "IP": "198.51.100.23", + "IPVerdict": "LikelyAttacker", + "Flagged": true + } + ], + "FormsSummary": { + "FlaggedActions": 1, + "Forms": [ + { + "FormId": "form-1", + "FormName": "Microsoft 365 account verification", + "Responses": 4, + "AnonymousResponses": 4, + "Views": 37, + "PhishingFlagged": false + } + ] + }, + "DelegatedAccess": [ + { + "Mailbox": "accounts@example.com", + "AccessRights": "FullAccess", + "KnownFrom": "Permission cache", + "AttackerOpened": 6, + "AttackerSynced": 0, + "AttackerSent": 0, + "Flagged": true + } + ], + "BlastRadius": [ + { + "UserPrincipalName": "finance.lead@example.com", + "UserId": "22222222-2222-2222-2222-222222222222", + "Reached": true, + "AttackerIPs": "198.51.100.23", + "SuccessfulSignIns": 1, + "FailedSignIns": 2, + "Actions": 4, + "Operations": "MailItemsAccessed x4", + "FirstSeen": "2026-08-04T04:00:00Z", + "LastSeen": "2026-08-04T04:20:00Z" + } + ] + } +} diff --git a/Config/ReportSamples/executive.json b/Config/ReportSamples/executive.json new file mode 100644 index 0000000000000..fa10f17dd7548 --- /dev/null +++ b/Config/ReportSamples/executive.json @@ -0,0 +1,183 @@ +{ + "UserStats": { + "licensedUsers": 128, + "unlicensedUsers": 12, + "guests": 9, + "globalAdmins": 3, + "permanentGlobalAdmins": 2, + "eligibleGlobalAdmins": 1, + "pimCapable": true + }, + "SecureScore": { + "currentScore": 61, + "maxScore": 100, + "percentageCurrent": 61, + "percentageVsSimilar": 54, + "percentageVsAllTenants": 49, + "trend": [ + { + "label": "Jul 29", + "value": 50 + }, + { + "label": "Jul 30", + "value": 52 + }, + { + "label": "Jul 31", + "value": 55 + }, + { + "label": "Aug 1", + "value": 57 + }, + { + "label": "Aug 2", + "value": 58 + }, + { + "label": "Aug 3", + "value": 60 + }, + { + "label": "Aug 4", + "value": 61 + } + ] + }, + "Licenses": [ + { + "name": "Microsoft 365 E3", + "used": "88", + "available": "12", + "total": "100" + }, + { + "name": "Exchange Online (Plan 1)", + "used": "27", + "available": "3", + "total": "30" + }, + { + "name": "Microsoft Defender for Office 365", + "used": "64", + "available": "36", + "total": "100" + } + ], + "Devices": [ + { + "name": "SAMPLE-LT-001", + "os": "Windows", + "compliance": "compliant", + "compliant": true, + "lastSync": "Aug 5, 2026", + "encrypted": true + }, + { + "name": "SAMPLE-LT-002", + "os": "Windows", + "compliance": "noncompliant", + "compliant": false, + "lastSync": "Jul 28, 2026", + "encrypted": false + }, + { + "name": "SAMPLE-MB-003", + "os": "iOS", + "compliance": "compliant", + "compliant": true, + "lastSync": "Aug 5, 2026", + "encrypted": true + }, + { + "name": "SAMPLE-MB-004", + "os": "Android", + "compliance": "compliant", + "compliant": true, + "lastSync": "Aug 4, 2026", + "encrypted": true + }, + { + "name": "CPC-SAMPLE-005", + "os": "Windows", + "compliance": "compliant", + "compliant": true, + "lastSync": "Aug 5, 2026", + "encrypted": true + } + ], + "CAPolicies": [ + { + "name": "Require MFA for all users", + "state": "enabled", + "controls": [ + "mfa" + ], + "controlsText": "MFA", + "applications": "All" + }, + { + "name": "Block legacy authentication", + "state": "enabled", + "controls": [ + "block" + ], + "controlsText": "Block", + "applications": "All" + }, + { + "name": "Require compliant device for Exchange", + "state": "enabledForReportingButNotEnforced", + "controls": [ + "compliantDevice" + ], + "controlsText": "Compliant Device", + "applications": "Exchange Online" + } + ], + "SecurityControls": [ + { + "name": "Anti-Phishing Policy", + "description": "Protects users from impersonation and spoofing attempts in email.", + "tags": "Exchange, Defender", + "status": "Compliant" + }, + { + "name": "Safe Links Policy", + "description": "Rewrites and scans links in mail and Office documents at click time.", + "tags": "Exchange, Defender", + "status": "Compliant" + }, + { + "name": "Unified Audit Log", + "description": "Records tenant activity so incidents can be investigated after the fact.", + "tags": "Compliance", + "status": "Compliant" + }, + { + "name": "Guest Access Restrictions", + "description": "Limits what guest accounts can see and do in the directory.", + "tags": "Identity", + "status": "Review" + }, + { + "name": "Password Expiry Disabled", + "description": "Removes forced rotation in favour of strong, unique passwords.", + "tags": "Identity", + "status": "Compliant" + }, + { + "name": "Disable Basic Authentication", + "description": "Blocks legacy protocols that cannot enforce multi-factor authentication.", + "tags": "Exchange, Identity", + "status": "Review" + }, + { + "name": "Windows Device Compliance", + "description": "Intune compliance policy requiring encryption, updates and a supported OS.", + "tags": "Intune", + "status": "Compliant" + } + ] +} diff --git a/Config/ReportSamples/licensing.json b/Config/ReportSamples/licensing.json new file mode 100644 index 0000000000000..90576fc4c7a4e --- /dev/null +++ b/Config/ReportSamples/licensing.json @@ -0,0 +1,913 @@ +{ + "Summary": { + "Tenant": "contoso.onmicrosoft.com", + "Currency": "USD", + "GeneratedAt": "2026-09-23T09:00:00.0000000+00:00", + "ReportPeriodDays": 90, + "InactiveDays": 90, + "TenureMonths": 6, + "RecommendDowngrades": true, + "RecommendUpgrades": true, + "RecommendTerms": true, + "ProtectSecurityFeatures": true, + "MonthlySpend": 2433.75, + "AssignedSeats": 130, + "TotalSeats": 202, + "LicensedUsers": 96, + "PriceCoverage": 0.992, + "ReclaimableMonthly": 412.75, + "ReclaimableSeats": 27, + "SignInDataAvailable": true, + "DowngradeMonthly": 301.75, + "DowngradeSeats": 30, + "ConsolidationMonthly": 3.5, + "TermMonthly": 120.3, + "ProtectInvestmentMonthly": 123.75, + "ProtectSeats": 13, + "TotalPotentialMonthly": 838.3, + "TotalPotentialAnnual": 10059.6, + "MonthlyCommitmentUplift": 0.2, + "SuggestionCount": 71, + "AnonymizedReports": false, + "DataAvailable": true, + "UsageDataAvailable": true, + "AppUsageDataAvailable": true, + "MailboxUsageDataAvailable": true, + "Sources": { + "Licenses": "Cache", + "Users": "Cache", + "ActivityDetail": "Cache", + "AppUsage": "Cache", + "MailboxUsage": "Cache", + "CopilotUsage": "Cache" + } + }, + "Suggestions": [], + "Optimization": { + "Summary": { + "MonthlySpend": 2433.75, + "AssignedSeats": 130, + "PriceCoverage": 0.992 + }, + "Opportunities": [ + { + "Tier": "UnassignedSeats", + "FindingLabel": "Unassigned", + "License": "Microsoft 365 Business Premium", + "skuId": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", + "Seats": 8, + "UnitCost": 22, + "MonthlySaving": 176, + "SuggestedAction": "Reduce seat count", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "UnassignedSeats", + "FindingLabel": "Unassigned", + "License": "Microsoft 365 E3", + "skuId": "05e9a617-0261-4cee-bb44-138d3ef5d965", + "Seats": 2, + "UnitCost": 36, + "MonthlySaving": 72, + "SuggestedAction": "Reduce seat count", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "DisabledAccount", + "FindingLabel": "Disabled user", + "License": "Microsoft 365 Business Premium", + "skuId": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", + "Seats": 2, + "UnitCost": 22, + "MonthlySaving": 44, + "SuggestedAction": "Remove license", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "UnassignedSeats", + "FindingLabel": "Unassigned", + "License": "Microsoft 365 Business Standard", + "skuId": "f245ecc8-75af-4f8e-b61f-27d8114de5f3", + "Seats": 3, + "UnitCost": 12.5, + "MonthlySaving": 37.5, + "SuggestedAction": "Reduce seat count", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "UnassignedSeats", + "FindingLabel": "Unassigned", + "License": "Microsoft 365 Business Basic", + "skuId": "3b555118-da6a-4418-894f-7df1e2096870", + "Seats": 6, + "UnitCost": 6, + "MonthlySaving": 36, + "SuggestedAction": "Reduce seat count", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "Inactive", + "FindingLabel": "Inactive 90d+", + "License": "Microsoft 365 Business Standard", + "skuId": "f245ecc8-75af-4f8e-b61f-27d8114de5f3", + "Seats": 2, + "UnitCost": 12.5, + "MonthlySaving": 25, + "SuggestedAction": "Review / remove", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "UnassignedSeats", + "FindingLabel": "Unassigned", + "License": "Microsoft 365 Apps for business", + "skuId": "cdd28e44-67e3-425e-be4c-737fab2899d3", + "Seats": 1, + "UnitCost": 8.25, + "MonthlySaving": 8.25, + "SuggestedAction": "Reduce seat count", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "Overlap", + "FindingLabel": "Redundant", + "License": "Exchange Online (Plan 1)", + "skuId": "4b9405b0-7788-4568-add1-99614e613b69", + "Seats": 1, + "UnitCost": 4, + "MonthlySaving": 4, + "SuggestedAction": "Remove redundant license", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "UnassignedSeats", + "FindingLabel": "Unassigned", + "License": "Exchange Online (Plan 1)", + "skuId": "4b9405b0-7788-4568-add1-99614e613b69", + "Seats": 1, + "UnitCost": 4, + "MonthlySaving": 4, + "SuggestedAction": "Reduce seat count", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "Downgrade", + "FindingLabel": "Mailbox-only", + "License": "Microsoft 365 E3", + "skuId": "05e9a617-0261-4cee-bb44-138d3ef5d965", + "Seats": 1, + "UnitCost": 36, + "MonthlySaving": 0, + "SuggestedAction": "Review: only using email", + "Users": [], + "PriceKnown": true + }, + { + "Tier": "UnassignedSeats", + "FindingLabel": "Unassigned", + "License": "Microsoft Teams Rooms Pro", + "skuId": "4fb214cb-a430-4a91-9c91-4976763aa78f", + "Seats": 1, + "UnitCost": null, + "MonthlySaving": 0, + "SuggestedAction": "Reduce seat count", + "Users": [], + "PriceKnown": false + } + ] + }, + "Downgrades": [ + { + "FromLicense": "Microsoft 365 Copilot", + "FromSkuId": "639dec6b-bb19-468b-871c-c5c441c4b0cb", + "ToLicense": "No license", + "ToSkuId": null, + "Action": "Remove", + "Seats": 5, + "UnitCost": 30, + "TargetCost": 0, + "UnitSaving": 30, + "MonthlySaving": 150, + "Keeps": [], + "Loses": [ + "Microsoft 365 Copilot" + ], + "Users": [] + }, + { + "FromLicense": "Microsoft 365 Business Standard", + "FromSkuId": "f245ecc8-75af-4f8e-b61f-27d8114de5f3", + "ToLicense": "Microsoft 365 Business Basic", + "ToSkuId": "3b555118-da6a-4418-894f-7df1e2096870", + "Action": "Downgrade", + "Seats": 9, + "UnitCost": 12.5, + "TargetCost": 6, + "UnitSaving": 6.5, + "MonthlySaving": 58.5, + "Keeps": [ + "Email and calendar", + "File storage and sharing", + "Teams chat and meetings" + ], + "Loses": [ + "Office desktop apps" + ], + "Users": [] + }, + { + "FromLicense": "Microsoft 365 E3", + "FromSkuId": "05e9a617-0261-4cee-bb44-138d3ef5d965", + "ToLicense": "Microsoft 365 Business Premium", + "ToSkuId": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", + "Action": "Downgrade", + "Seats": 3, + "UnitCost": 36, + "TargetCost": 22, + "UnitSaving": 14, + "MonthlySaving": 42, + "Keeps": [ + "Advanced sign-in security", + "Data protection and labelling", + "Device management", + "Device threat protection", + "Email and calendar", + "File storage and sharing", + "Large mailbox and archive", + "Office desktop apps", + "Teams chat and meetings", + "Windows upgrade rights" + ], + "Loses": [], + "Users": [] + }, + { + "FromLicense": "Microsoft 365 Business Standard", + "FromSkuId": "f245ecc8-75af-4f8e-b61f-27d8114de5f3", + "ToLicense": "No license", + "ToSkuId": null, + "Action": "Remove", + "Seats": 1, + "UnitCost": 12.5, + "TargetCost": 0, + "UnitSaving": 12.5, + "MonthlySaving": 12.5, + "Keeps": [], + "Loses": [ + "Email and calendar", + "File storage and sharing", + "Office desktop apps", + "Teams chat and meetings" + ], + "Users": [] + }, + { + "FromLicense": "Microsoft 365 Business Standard", + "FromSkuId": "f245ecc8-75af-4f8e-b61f-27d8114de5f3", + "ToLicense": "Microsoft 365 Apps for business", + "ToSkuId": "cdd28e44-67e3-425e-be4c-737fab2899d3", + "Action": "Downgrade", + "Seats": 2, + "UnitCost": 12.5, + "TargetCost": 8.25, + "UnitSaving": 4.25, + "MonthlySaving": 8.5, + "Keeps": [ + "File storage and sharing", + "Office desktop apps" + ], + "Loses": [ + "Email and calendar", + "Teams chat and meetings" + ], + "Users": [] + }, + { + "FromLicense": "Microsoft 365 Apps for business", + "FromSkuId": "cdd28e44-67e3-425e-be4c-737fab2899d3", + "ToLicense": "No license", + "ToSkuId": null, + "Action": "Remove", + "Seats": 1, + "UnitCost": 8.25, + "TargetCost": 0, + "UnitSaving": 8.25, + "MonthlySaving": 8.25, + "Keeps": [], + "Loses": [ + "File storage and sharing", + "Office desktop apps" + ], + "Users": [] + }, + { + "FromLicense": "Microsoft 365 Business Basic", + "FromSkuId": "3b555118-da6a-4418-894f-7df1e2096870", + "ToLicense": "Exchange Online (Plan 1)", + "ToSkuId": "4b9405b0-7788-4568-add1-99614e613b69", + "Action": "Downgrade", + "Seats": 4, + "UnitCost": 6, + "TargetCost": 4, + "UnitSaving": 2, + "MonthlySaving": 8, + "Keeps": [ + "Email and calendar" + ], + "Loses": [ + "File storage and sharing", + "Teams chat and meetings" + ], + "Users": [] + }, + { + "FromLicense": "Microsoft 365 Business Basic", + "FromSkuId": "3b555118-da6a-4418-894f-7df1e2096870", + "ToLicense": "No license", + "ToSkuId": null, + "Action": "Remove", + "Seats": 1, + "UnitCost": 6, + "TargetCost": 0, + "UnitSaving": 6, + "MonthlySaving": 6, + "Keeps": [], + "Loses": [ + "Email and calendar", + "File storage and sharing", + "Teams chat and meetings" + ], + "Users": [] + }, + { + "FromLicense": "Exchange Online (Plan 1)", + "FromSkuId": "4b9405b0-7788-4568-add1-99614e613b69", + "ToLicense": "Exchange Online Kiosk", + "ToSkuId": "80b2d799-d2ba-4d2a-8842-fb0d0f3a4b82", + "Action": "Downgrade", + "Seats": 2, + "UnitCost": 4, + "TargetCost": 2, + "UnitSaving": 2, + "MonthlySaving": 4, + "Keeps": [ + "Email and calendar" + ], + "Loses": [], + "Users": [] + }, + { + "FromLicense": "Microsoft 365 Business Basic", + "FromSkuId": "3b555118-da6a-4418-894f-7df1e2096870", + "ToLicense": "Microsoft Teams Essentials", + "ToSkuId": "3f4d6b3c-4a8c-4aaf-9bb7-1e3d9a3b1f7c", + "Action": "Downgrade", + "Seats": 2, + "UnitCost": 6, + "TargetCost": 4, + "UnitSaving": 2, + "MonthlySaving": 4, + "Keeps": [ + "Teams chat and meetings" + ], + "Loses": [ + "Email and calendar", + "File storage and sharing" + ], + "Users": [] + } + ], + "Upgrades": [ + { + "Type": "Consolidate", + "FromLicenses": [ + "Microsoft 365 Apps for business", + "Microsoft 365 Business Basic" + ], + "FromSkuIds": [ + "cdd28e44-67e3-425e-be4c-737fab2899d3", + "3b555118-da6a-4418-894f-7df1e2096870" + ], + "ToLicense": "Microsoft 365 Business Standard", + "ToSkuId": "f245ecc8-75af-4f8e-b61f-27d8114de5f3", + "Seats": 2, + "UnitCost": 14.25, + "TargetCost": 12.5, + "UnitDelta": -1.75, + "MonthlyDelta": -3.5, + "Gains": [], + "Users": [] + }, + { + "Type": "Protect", + "FromLicenses": [ + "Exchange Online (Plan 1)", + "Microsoft 365 Apps for business" + ], + "FromSkuIds": [ + "4b9405b0-7788-4568-add1-99614e613b69", + "cdd28e44-67e3-425e-be4c-737fab2899d3" + ], + "ToLicense": "Microsoft 365 Business Premium", + "ToSkuId": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", + "Seats": 1, + "UnitCost": 12.25, + "TargetCost": 22, + "UnitDelta": 9.75, + "MonthlyDelta": 9.75, + "Gains": [ + "Advanced sign-in security", + "Data protection and labelling", + "Device management", + "Device threat protection", + "Teams chat and meetings" + ], + "Users": [] + }, + { + "Type": "Protect", + "FromLicenses": [ + "Microsoft 365 Business Standard" + ], + "FromSkuIds": [ + "f245ecc8-75af-4f8e-b61f-27d8114de5f3" + ], + "ToLicense": "Microsoft 365 Business Premium", + "ToSkuId": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", + "Seats": 12, + "UnitCost": 12.5, + "TargetCost": 22, + "UnitDelta": 9.5, + "MonthlyDelta": 114, + "Gains": [ + "Advanced email threat protection", + "Advanced sign-in security", + "Data protection and labelling", + "Device management", + "Device threat protection" + ], + "Users": [] + } + ], + "Terms": [ + { + "License": "Microsoft 365 Business Premium", + "skuId": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", + "TotalSeats": 68, + "AssignedSeats": 60, + "StableSeats": 52, + "MonthlySeats": 30, + "YearlySeats": 38, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 52, + "RecommendedMonthly": 8, + "ConvertibleSeats": 14, + "UnitCost": 22, + "MonthlySaving": 61.6, + "LockedUnusedSeats": 0, + "NextRenewalDays": 120, + "PriceKnown": true + }, + { + "License": "Microsoft 365 Business Standard", + "skuId": "f245ecc8-75af-4f8e-b61f-27d8114de5f3", + "TotalSeats": 25, + "AssignedSeats": 22, + "StableSeats": 15, + "MonthlySeats": 25, + "YearlySeats": 0, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 15, + "RecommendedMonthly": 7, + "ConvertibleSeats": 15, + "UnitCost": 12.5, + "MonthlySaving": 37.5, + "LockedUnusedSeats": 0, + "NextRenewalDays": 12, + "PriceKnown": true + }, + { + "License": "Microsoft 365 Copilot", + "skuId": "639dec6b-bb19-468b-871c-c5c441c4b0cb", + "TotalSeats": 8, + "AssignedSeats": 8, + "StableSeats": 2, + "MonthlySeats": 8, + "YearlySeats": 0, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 2, + "RecommendedMonthly": 6, + "ConvertibleSeats": 2, + "UnitCost": 30, + "MonthlySaving": 12, + "LockedUnusedSeats": 0, + "NextRenewalDays": 20, + "PriceKnown": true + }, + { + "License": "Visio Plan 2", + "skuId": "c5928f49-12ba-48f7-ada3-0d743a3601d5", + "TotalSeats": 2, + "AssignedSeats": 2, + "StableSeats": 2, + "MonthlySeats": 2, + "YearlySeats": 0, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 2, + "RecommendedMonthly": 0, + "ConvertibleSeats": 2, + "UnitCost": 15, + "MonthlySaving": 6, + "LockedUnusedSeats": 0, + "NextRenewalDays": 8, + "PriceKnown": true + }, + { + "License": "Exchange Online (Plan 1)", + "skuId": "4b9405b0-7788-4568-add1-99614e613b69", + "TotalSeats": 5, + "AssignedSeats": 4, + "StableSeats": 4, + "MonthlySeats": 5, + "YearlySeats": 0, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 4, + "RecommendedMonthly": 0, + "ConvertibleSeats": 4, + "UnitCost": 4, + "MonthlySaving": 3.2, + "LockedUnusedSeats": 0, + "NextRenewalDays": 8, + "PriceKnown": true + }, + { + "License": "Microsoft 365 E3", + "skuId": "05e9a617-0261-4cee-bb44-138d3ef5d965", + "TotalSeats": 12, + "AssignedSeats": 10, + "StableSeats": 9, + "MonthlySeats": 0, + "YearlySeats": 12, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 9, + "RecommendedMonthly": 1, + "ConvertibleSeats": 0, + "UnitCost": 36, + "MonthlySaving": 0, + "LockedUnusedSeats": 2, + "NextRenewalDays": 200, + "PriceKnown": true + }, + { + "License": "Microsoft 365 Business Basic", + "skuId": "3b555118-da6a-4418-894f-7df1e2096870", + "TotalSeats": 20, + "AssignedSeats": 14, + "StableSeats": 10, + "MonthlySeats": 0, + "YearlySeats": 20, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 10, + "RecommendedMonthly": 4, + "ConvertibleSeats": 0, + "UnitCost": 6, + "MonthlySaving": 0, + "LockedUnusedSeats": 6, + "NextRenewalDays": 45, + "PriceKnown": true + }, + { + "License": "Power BI Pro", + "skuId": "f8a1db68-be16-40ed-86d5-cb42ce701560", + "TotalSeats": 6, + "AssignedSeats": 6, + "StableSeats": 6, + "MonthlySeats": 0, + "YearlySeats": 0, + "UnknownTermSeats": 6, + "TermKnown": false, + "RecommendedAnnual": 6, + "RecommendedMonthly": 0, + "ConvertibleSeats": null, + "UnitCost": 14, + "MonthlySaving": 0, + "LockedUnusedSeats": 0, + "NextRenewalDays": null, + "PriceKnown": true + }, + { + "License": "Microsoft 365 Apps for business", + "skuId": "cdd28e44-67e3-425e-be4c-737fab2899d3", + "TotalSeats": 4, + "AssignedSeats": 3, + "StableSeats": 3, + "MonthlySeats": 0, + "YearlySeats": 4, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 3, + "RecommendedMonthly": 0, + "ConvertibleSeats": 0, + "UnitCost": 8.25, + "MonthlySaving": 0, + "LockedUnusedSeats": 1, + "NextRenewalDays": null, + "PriceKnown": true + }, + { + "License": "Microsoft Teams Rooms Pro", + "skuId": "4fb214cb-a430-4a91-9c91-4976763aa78f", + "TotalSeats": 2, + "AssignedSeats": 1, + "StableSeats": 1, + "MonthlySeats": 0, + "YearlySeats": 2, + "UnknownTermSeats": 0, + "TermKnown": true, + "RecommendedAnnual": 1, + "RecommendedMonthly": 0, + "ConvertibleSeats": 0, + "UnitCost": null, + "MonthlySaving": 0, + "LockedUnusedSeats": 1, + "NextRenewalDays": 300, + "PriceKnown": false + } + ], + "Products": [ + { + "License": "Microsoft 365 Business Premium", + "skuId": "cbdc14ab-d96c-4c30-b9f4-6ada7cdc1d46", + "Family": null, + "TotalSeats": 68, + "AssignedSeats": 60, + "UnusedSeats": 8, + "TenantLevel": false, + "UnitCost": 22, + "MonthlySpend": 1320, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Microsoft 365 E3", + "skuId": "05e9a617-0261-4cee-bb44-138d3ef5d965", + "Family": null, + "TotalSeats": 12, + "AssignedSeats": 10, + "UnusedSeats": 2, + "TenantLevel": false, + "UnitCost": 36, + "MonthlySpend": 360, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Microsoft 365 Business Standard", + "skuId": "f245ecc8-75af-4f8e-b61f-27d8114de5f3", + "Family": null, + "TotalSeats": 25, + "AssignedSeats": 22, + "UnusedSeats": 3, + "TenantLevel": false, + "UnitCost": 12.5, + "MonthlySpend": 275, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Microsoft 365 Copilot", + "skuId": "639dec6b-bb19-468b-871c-c5c441c4b0cb", + "Family": null, + "TotalSeats": 8, + "AssignedSeats": 8, + "UnusedSeats": 0, + "TenantLevel": false, + "UnitCost": 30, + "MonthlySpend": 240, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Microsoft 365 Business Basic", + "skuId": "3b555118-da6a-4418-894f-7df1e2096870", + "Family": null, + "TotalSeats": 20, + "AssignedSeats": 14, + "UnusedSeats": 6, + "TenantLevel": false, + "UnitCost": 6, + "MonthlySpend": 84, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Power BI Pro", + "skuId": "f8a1db68-be16-40ed-86d5-cb42ce701560", + "Family": null, + "TotalSeats": 6, + "AssignedSeats": 6, + "UnusedSeats": 0, + "TenantLevel": false, + "UnitCost": 14, + "MonthlySpend": 84, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Visio Plan 2", + "skuId": "c5928f49-12ba-48f7-ada3-0d743a3601d5", + "Family": null, + "TotalSeats": 2, + "AssignedSeats": 2, + "UnusedSeats": 0, + "TenantLevel": false, + "UnitCost": 15, + "MonthlySpend": 30, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Microsoft 365 Apps for business", + "skuId": "cdd28e44-67e3-425e-be4c-737fab2899d3", + "Family": null, + "TotalSeats": 4, + "AssignedSeats": 3, + "UnusedSeats": 1, + "TenantLevel": false, + "UnitCost": 8.25, + "MonthlySpend": 24.75, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Exchange Online (Plan 1)", + "skuId": "4b9405b0-7788-4568-add1-99614e613b69", + "Family": null, + "TotalSeats": 5, + "AssignedSeats": 4, + "UnusedSeats": 1, + "TenantLevel": false, + "UnitCost": 4, + "MonthlySpend": 16, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Office 365 Extra File Storage", + "skuId": "99049c9c-6011-4908-bf17-15f496e6519d", + "Family": null, + "TotalSeats": 50, + "AssignedSeats": 0, + "UnusedSeats": 0, + "TenantLevel": true, + "UnitCost": 0.2, + "MonthlySpend": 0, + "PriceKnown": true, + "Capabilities": [] + }, + { + "License": "Microsoft Teams Rooms Pro", + "skuId": "4fb214cb-a430-4a91-9c91-4976763aa78f", + "Family": null, + "TotalSeats": 2, + "AssignedSeats": 1, + "UnusedSeats": 1, + "TenantLevel": false, + "UnitCost": null, + "MonthlySpend": null, + "PriceKnown": false, + "Capabilities": [] + } + ], + "Capabilities": [ + { + "id": "email", + "label": "Email and calendar", + "description": "A business mailbox with calendar and contacts.", + "measurable": true + }, + { + "id": "largeMailbox", + "label": "Large mailbox and archive", + "description": "A 100 GB mailbox with an unlimited email archive. Only needed when a mailbox is near the 50 GB limit or uses an archive.", + "measurable": true + }, + { + "id": "desktopApps", + "label": "Office desktop apps", + "description": "Word, Excel, PowerPoint and Outlook installed on a PC or Mac.", + "measurable": true + }, + { + "id": "teams", + "label": "Teams chat and meetings", + "description": "Microsoft Teams for chat, calls and online meetings.", + "measurable": true + }, + { + "id": "files", + "label": "File storage and sharing", + "description": "OneDrive and SharePoint for storing and sharing documents.", + "measurable": true + }, + { + "id": "copilot", + "label": "Microsoft 365 Copilot", + "description": "The AI assistant inside Word, Excel, Outlook and Teams.", + "measurable": true + }, + { + "id": "deviceManagement", + "label": "Device management", + "description": "Intune: enrolling, securing and wiping company laptops and phones.", + "measurable": false + }, + { + "id": "signInSecurity", + "label": "Advanced sign-in security", + "description": "Entra ID P1: Conditional Access rules that decide who can sign in from where.", + "measurable": false + }, + { + "id": "identityRisk", + "label": "Identity risk protection", + "description": "Entra ID P2: automatic detection of risky sign-ins and privileged access controls.", + "measurable": false + }, + { + "id": "identityGovernance", + "label": "Identity governance", + "description": "Access reviews and lifecycle workflows for user access.", + "measurable": false + }, + { + "id": "endpointSecurity", + "label": "Device threat protection", + "description": "Microsoft Defender for Business or Endpoint: antivirus and attack detection on devices.", + "measurable": false + }, + { + "id": "emailSecurity", + "label": "Advanced email threat protection", + "description": "Defender for Office 365: safe links, safe attachments and phishing protection.", + "measurable": false + }, + { + "id": "cloudAppSecurity", + "label": "Cloud app security", + "description": "Defender for Cloud Apps: visibility and control over cloud services staff use.", + "measurable": false + }, + { + "id": "informationProtection", + "label": "Data protection and labelling", + "description": "Purview sensitivity labels and rights management for confidential documents.", + "measurable": false + }, + { + "id": "compliance", + "label": "Advanced compliance", + "description": "eDiscovery, advanced audit, insider risk and customer lockbox.", + "measurable": false + }, + { + "id": "phone", + "label": "Teams Phone", + "description": "Making and receiving regular phone calls through Teams.", + "measurable": false + }, + { + "id": "audioConferencing", + "label": "Dial-in meetings", + "description": "Phone dial-in numbers for Teams meetings.", + "measurable": false + }, + { + "id": "analytics", + "label": "Power BI reporting", + "description": "Building and sharing interactive business reports.", + "measurable": false + }, + { + "id": "windows", + "label": "Windows upgrade rights", + "description": "Upgrading Windows Pro to Enterprise or Business edition.", + "measurable": false + } + ] +} diff --git a/Config/ReportSamples/mailFlow.json b/Config/ReportSamples/mailFlow.json new file mode 100644 index 0000000000000..0253b7165d416 --- /dev/null +++ b/Config/ReportSamples/mailFlow.json @@ -0,0 +1,188 @@ +{ + "days": 14, + "totals": { + "GoodMail": 48210, + "TransportRules": 1340, + "SpamDetections": 6120, + "EdgeBlockSpam": 3980, + "EmailPhish": 412, + "EmailMalware": 37 + }, + "directionTotals": { + "Inbound": 39280, + "Outbound": 8940, + "IntraOrg": 11879 + }, + "daily": [ + { + "date": "2026-08-04", + "GoodMail": 3410, + "TransportRules": 96, + "SpamDetections": 430, + "EdgeBlockSpam": 281, + "EmailPhish": 29, + "EmailMalware": 3 + }, + { + "date": "2026-08-05", + "GoodMail": 3688, + "TransportRules": 104, + "SpamDetections": 468, + "EdgeBlockSpam": 302, + "EmailPhish": 33, + "EmailMalware": 2 + }, + { + "date": "2026-08-06", + "GoodMail": 3572, + "TransportRules": 88, + "SpamDetections": 451, + "EdgeBlockSpam": 295, + "EmailPhish": 31, + "EmailMalware": 4 + }, + { + "date": "2026-08-07", + "GoodMail": 3740, + "TransportRules": 112, + "SpamDetections": 502, + "EdgeBlockSpam": 318, + "EmailPhish": 38, + "EmailMalware": 1 + }, + { + "date": "2026-08-08", + "GoodMail": 3495, + "TransportRules": 97, + "SpamDetections": 476, + "EdgeBlockSpam": 304, + "EmailPhish": 35, + "EmailMalware": 3 + }, + { + "date": "2026-08-09", + "GoodMail": 1180, + "TransportRules": 21, + "SpamDetections": 268, + "EdgeBlockSpam": 174, + "EmailPhish": 12, + "EmailMalware": 0 + }, + { + "date": "2026-08-10", + "GoodMail": 1042, + "TransportRules": 18, + "SpamDetections": 251, + "EdgeBlockSpam": 166, + "EmailPhish": 10, + "EmailMalware": 1 + }, + { + "date": "2026-08-11", + "GoodMail": 3820, + "TransportRules": 118, + "SpamDetections": 529, + "EdgeBlockSpam": 341, + "EmailPhish": 41, + "EmailMalware": 5 + }, + { + "date": "2026-08-12", + "GoodMail": 3903, + "TransportRules": 121, + "SpamDetections": 544, + "EdgeBlockSpam": 352, + "EmailPhish": 44, + "EmailMalware": 4 + }, + { + "date": "2026-08-13", + "GoodMail": 3766, + "TransportRules": 109, + "SpamDetections": 511, + "EdgeBlockSpam": 329, + "EmailPhish": 36, + "EmailMalware": 2 + }, + { + "date": "2026-08-14", + "GoodMail": 3841, + "TransportRules": 114, + "SpamDetections": 498, + "EdgeBlockSpam": 321, + "EmailPhish": 34, + "EmailMalware": 3 + }, + { + "date": "2026-08-15", + "GoodMail": 3612, + "TransportRules": 102, + "SpamDetections": 470, + "EdgeBlockSpam": 303, + "EmailPhish": 30, + "EmailMalware": 4 + }, + { + "date": "2026-08-16", + "GoodMail": 1214, + "TransportRules": 22, + "SpamDetections": 264, + "EdgeBlockSpam": 172, + "EmailPhish": 20, + "EmailMalware": 3 + }, + { + "date": "2026-08-17", + "GoodMail": 1127, + "TransportRules": 18, + "SpamDetections": 258, + "EdgeBlockSpam": 322, + "EmailPhish": 19, + "EmailMalware": 2 + } + ], + "topSenders": [ + { + "Name": "notifications@sample-crm.example.com", + "Count": 4820 + }, + { + "Name": "billing@example.com", + "Count": 3115 + }, + { + "Name": "scanner-3f@example.com", + "Count": 2064 + }, + { + "Name": "sample.user@example.com", + "Count": 1893 + }, + { + "Name": "helpdesk@example.com", + "Count": 1477 + } + ], + "topSpamRecipients": [ + { + "Name": "info@example.com", + "Count": 1840 + }, + { + "Name": "sales@example.com", + "Count": 1226 + }, + { + "Name": "sample.user@example.com", + "Count": 744 + }, + { + "Name": "accounts@example.com", + "Count": 517 + }, + { + "Name": "careers@example.com", + "Count": 388 + } + ] +} diff --git a/Config/ReportSamples/permissions.json b/Config/ReportSamples/permissions.json new file mode 100644 index 0000000000000..4a0fd7256873d --- /dev/null +++ b/Config/ReportSamples/permissions.json @@ -0,0 +1,27 @@ +{ + "summary": { + "sitesScanned": 12, + "librariesScanned": 34, + "totalAssignments": 156, + "broadClaimGrants": 2, + "externalGrants": 5, + "directFullControlGrants": 3, + "uniquePermissionLibraries": 7 + }, + "assignments": [ + { + "siteName": "Sample Marketing", + "libraryName": "Documents", + "principal": "Everyone except external users", + "permission": "Edit", + "type": "Broad claim" + }, + { + "siteName": "Sample Finance", + "libraryName": "Documents", + "principal": "partner@example.com", + "permission": "Full Control", + "type": "External" + } + ] +} diff --git a/Config/ReportSamples/reportBuilder.json b/Config/ReportSamples/reportBuilder.json new file mode 100644 index 0000000000000..d00cea8b06bb9 --- /dev/null +++ b/Config/ReportSamples/reportBuilder.json @@ -0,0 +1,259 @@ +{ + "blocks": [ + { + "type": "cover", + "title": "Quarterly Security", + "coverAccent": "Review", + "subtitle": "How the tenant stood this quarter, what changed, and what comes next.", + "coverLabel": "Security Review", + "static": true + }, + { + "id": "sample-1", + "type": "blank", + "title": "Summary", + "static": true, + "content": "

This preview uses sample data so you can see how your branding lands before running a report against a real tenant.

" + }, + { + "type": "infobox", + "title": "Why this matters", + "content": "Identity is the perimeter: **every** control in this report starts with who can sign in, and from where.", + "static": true + }, + { + "type": "richbullets", + "title": "Key points", + "items": [ + { + "label": "MFA is enforced.", + "text": "All 128 licensed users are covered by a Conditional Access policy." + }, + { + "label": "Legacy authentication is blocked.", + "text": "No sign-ins over IMAP, POP or SMTP AUTH in the last 30 days." + } + ], + "static": true + }, + { + "type": "note", + "content": "Figures are taken from the last completed sync, not read live.", + "static": true + }, + { + "type": "page", + "title": "Findings in detail", + "subtitle": "What we found, and what to do about it", + "static": true + }, + { + "id": "sample-2", + "type": "scorecard", + "title": "Environment Overview", + "static": true, + "stats": [ + { + "label": "Licensed Users", + "value": "128" + }, + { + "label": "Devices", + "value": "96" + }, + { + "label": "Global Admins", + "value": "3", + "caption": "Target: 2–4" + }, + { + "label": "Guests", + "value": "9" + } + ] + }, + { + "id": "sample-3", + "type": "chart", + "title": "Device Compliance", + "static": true, + "chartKind": "donut", + "chartCentreLabel": "Devices", + "chartData": [ + { + "label": "Compliant", + "value": 78 + }, + { + "label": "Non-compliant", + "value": 14 + }, + { + "label": "Not evaluated", + "value": 4 + } + ] + }, + { + "id": "sample-4", + "type": "chart", + "title": "Secure Score Trend", + "static": true, + "chartKind": "trend", + "chartMax": 100, + "chartCaption": "Current: 61 / 100 (61%)", + "chartData": [ + { + "label": "Jul 29", + "value": 50 + }, + { + "label": "Jul 31", + "value": 55 + }, + { + "label": "Aug 2", + "value": 58 + }, + { + "label": "Aug 4", + "value": 61 + } + ] + }, + { + "id": "sample-5", + "type": "progress", + "title": "Control Coverage", + "static": true, + "items": [ + { + "label": "MFA enforced", + "value": 92, + "max": 100 + }, + { + "label": "Disk encryption", + "value": 78, + "max": 100 + }, + { + "label": "Defender onboarded", + "value": 64, + "max": 100 + } + ] + }, + { + "type": "richtable", + "title": "Top findings", + "columns": [ + { + "header": "Finding", + "key": "c1" + }, + { + "header": "Impact", + "key": "c2" + }, + { + "header": "Owner", + "key": "c3" + } + ], + "rows": [ + { + "c1": "3 stale guest accounts", + "c2": "Medium", + "c3": "IT" + }, + { + "c1": "2 devices out of compliance", + "c2": "Low", + "c3": "Service desk" + } + ], + "static": true + }, + { + "type": "infoboxcolumns", + "columns": 2, + "items": [ + { + "title": "Done this quarter", + "content": "Passwordless sign-in rolled out to 40% of staff." + }, + { + "title": "Next quarter", + "content": "Extend it to the remaining sites and the shared mailboxes." + } + ], + "static": true + }, + { + "id": "sample-6", + "type": "pagebreak", + "title": "", + "static": true + }, + { + "id": "sample-7", + "type": "hero", + "title": "seconds", + "static": true, + "heroHighlight": "39", + "heroSubText": "a business falls victim to ransomware", + "heroFooterText": "Proactive defense beats reactive recovery", + "heroImage": "/reportImages/working.jpg" + }, + { + "id": "sample-8", + "type": "test", + "title": "Multi-factor authentication", + "status": "Failed", + "static": false, + "content": "14 of 128 accounts can still sign in without a second factor.\n\n## Results\n\n| Account | Method | Last sign-in |\n| --- | --- | --- |\n| sample.one@example.com | None | 2 days ago |\n| sample.two@example.com | None | 9 days ago |\n" + }, + { + "id": "sample-9", + "type": "chart", + "title": "Devices by Platform", + "static": true, + "chartKind": "bar", + "chartData": [ + { + "label": "Windows", + "value": 62 + }, + { + "label": "macOS", + "value": 18 + }, + { + "label": "iOS", + "value": 12 + }, + { + "label": "Android", + "value": 4 + } + ] + }, + { + "id": "sample-10", + "type": "database", + "title": "Query Results", + "static": true, + "format": "text", + "content": "| Policy | Identifier | State |\n| --- | --- | --- |\n| Baseline | 8f2a1c4e-6b3d-4f5a-9e7c-1d2b3a4c5e6f | Enabled |\n| Hardened | Microsoft_Defender_for_Business_Servers | Report only |\n" + }, + { + "id": "sample-11", + "type": "database", + "title": "Raw Response", + "static": true, + "format": "json", + "content": "{\n \"tenant\": \"contoso.com\",\n \"policies\": 191,\n \"enabled\": 5\n}" + } + ] +} diff --git a/Config/ReportSamples/shadowAI.json b/Config/ReportSamples/shadowAI.json new file mode 100644 index 0000000000000..c5a4f9f6fcede --- /dev/null +++ b/Config/ReportSamples/shadowAI.json @@ -0,0 +1,103 @@ +{ + "summary": { + "aiToolsDetected": 18, + "deviceInstalls": 36, + "consentedAiApps": 5, + "highRiskTools": 3, + "sanctionedTools": 2 + }, + "byRisk": [ + { + "risk": "High", + "tools": 3 + }, + { + "risk": "Medium", + "tools": 7 + }, + { + "risk": "Low", + "tools": 6 + }, + { + "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 + } + ], + "detectedApps": [ + { + "application": "Sample AI Assistant Desktop", + "aiTool": "Sample AI Assistant", + "vendor": "Example Corp", + "category": "Chat", + "risk": "High", + "status": "Unsanctioned", + "deviceCount": 22 + }, + { + "application": "Sample Code Helper", + "aiTool": "Sample Code Helper", + "vendor": "Example Labs", + "category": "Development", + "risk": "Medium", + "status": "Unsanctioned", + "deviceCount": 14 + }, + { + "application": "Sample Notetaker", + "aiTool": "Sample Notetaker", + "vendor": "Example Corp", + "category": "Meetings", + "risk": "Informational", + "status": "Sanctioned", + "deviceCount": 11 + } + ], + "consentedApps": [ + { + "applicationId": "00000000-0000-0000-0000-000000000001", + "application": "Sample AI Connector", + "aiTool": "Sample AI Assistant", + "vendor": "Example Corp", + "category": "Chat", + "risk": "High", + "status": "Unsanctioned", + "activeUsersLast7Days": 18, + "firstConsentedDateTime": "2026-06-11T10:22:00Z" + }, + { + "applicationId": "00000000-0000-0000-0000-000000000002", + "application": "Sample Meeting Notes", + "aiTool": "Sample Notetaker", + "vendor": "Example Corp", + "category": "Meetings", + "risk": "Informational", + "status": "Sanctioned", + "activeUsersLast7Days": 24, + "firstConsentedDateTime": "2026-03-02T14:05:00Z" + } + ] +} diff --git a/Config/ReportSamples/sharing.json b/Config/ReportSamples/sharing.json new file mode 100644 index 0000000000000..d7f89977f75d3 --- /dev/null +++ b/Config/ReportSamples/sharing.json @@ -0,0 +1,53 @@ +{ + "summary": { + "totalLinks": 24, + "itemsShared": 18, + "externalRecipients": 6, + "anonymousLinks": 4, + "anonymousEditLinks": 1, + "neverExpiringAnonymous": 2, + "folderShares": 3, + "externalLinks": 6, + "sharePointSites": 5, + "teamsSites": 3, + "oneDriveAccounts": 12 + }, + "links": [ + { + "itemName": "Sample Proposal.docx", + "siteName": "Sample Marketing", + "linkType": "Anonymous", + "scope": "Edit", + "expires": "Never", + "recipients": "Anyone with the link" + }, + { + "itemName": "Sample Budget.xlsx", + "siteName": "Sample Finance", + "linkType": "External", + "scope": "View", + "expires": "2026-12-31", + "recipients": "partner@example.com" + } + ], + "topRecipients": [ + { + "recipient": "partner@example.com", + "links": 5 + }, + { + "recipient": "supplier@example.net", + "links": 3 + } + ], + "topLibraries": [ + { + "library": "Sample Marketing / Documents", + "links": 9 + }, + { + "library": "Sample Finance / Documents", + "links": 6 + } + ] +} diff --git a/Config/SAMManifest.json b/Config/SAMManifest.json index b4f29624ddd64..1ebb413e38985 100644 --- a/Config/SAMManifest.json +++ b/Config/SAMManifest.json @@ -743,6 +743,15 @@ "type": "Scope" } ] + }, + { + "resourceAppId": "09abbdfd-ed23-44ee-a2d9-a627aa1c90f3", + "resourceAccess": [ + { + "id": "68067c9d-a1be-4603-b095-3eaf54a42510", + "type": "Role" + } + ] } ], "servicePrincipalLockConfiguration": { diff --git a/Config/SecuritySimulations/CAAnalysis/AppDescriptions.json b/Config/SecuritySimulations/CAAnalysis/AppDescriptions.json new file mode 100644 index 0000000000000..fdd07d236aa58 --- /dev/null +++ b/Config/SecuritySimulations/CAAnalysis/AppDescriptions.json @@ -0,0 +1,33 @@ +[ + { "appId": "00000002-0000-0000-c000-000000000000", "displayName": "Windows Azure Active Directory", "purpose": "The older directory service interface, used by legacy applications to read and write users, groups and applications.", "commonExclusionReason": "Sometimes exempted so older applications that have not moved to Microsoft Graph keep working. Exempting it also lets every user read basic profile details without meeting the policy.", "exclusionRisk": "critical" }, + { "appId": "00000003-0000-0000-c000-000000000000", "displayName": "Microsoft Graph", "purpose": "The main interface to all Microsoft 365 services. Almost every Microsoft and third-party application uses it to reach mail, files, users, groups and Teams.", "commonExclusionReason": "Rarely has a valid reason to be exempt. Exempting it lets every application read basic profile and directory details without meeting the policy.", "exclusionRisk": "critical" }, + { "appId": "0000000c-0000-0000-c000-000000000000", "displayName": "Microsoft App Access Panel", "purpose": "The My Apps portal where people discover and launch the applications assigned to them.", "commonExclusionReason": "Exempted so people can browse their application list without a multifactor prompt. Low risk, because opening an application still triggers that application's own policy.", "exclusionRisk": "low" }, + { "appId": "2793995e-0a7d-40d7-bd35-6968ba142197", "displayName": "My Apps", "purpose": "The newer My Apps experience for finding and launching assigned applications.", "commonExclusionReason": "Exempted for the same reason as the App Access Panel. Launching an application still triggers that application's own policy.", "exclusionRisk": "low" }, + { "appId": "8c59ead7-d703-4a27-9e55-c96a0054c8d2", "displayName": "My Profile", "purpose": "The My Account portal where people manage their profile, sign-in methods, devices and privacy settings.", "commonExclusionReason": "Exempted so people can update their sign-in methods without being blocked. Without the exemption some people cannot register multifactor methods at all.", "exclusionRisk": "medium" }, + { "appId": "797f4846-ba00-4fd7-ba43-dac1f8f63013", "displayName": "Azure Resource Manager", "purpose": "The management layer for all Azure resources. Every portal, command-line and automation operation in Azure goes through it.", "commonExclusionReason": "Exempted so automation and service accounts can manage Azure resources. High risk, because a stolen credential then gives control of the entire Azure estate.", "exclusionRisk": "critical" }, + { "appId": "c44b4083-3bb0-49c1-b47d-974e53cbdf3c", "displayName": "Azure Portal", "purpose": "The Azure management website that gives visual access to every Azure service.", "commonExclusionReason": "Rarely exempted. Because the portal works through Azure Resource Manager, a policy on that service already covers the portal.", "exclusionRisk": "critical" }, + { "appId": "372140e0-b3b7-4226-8ef9-d57986796201", "displayName": "Azure Windows VM Sign-In", "purpose": "Lets people sign in to Azure Windows virtual machines with their Microsoft Entra account over remote desktop instead of local credentials.", "commonExclusionReason": "Exempted from multifactor policies because remote desktop sign-in to a virtual machine cannot show an interactive prompt. Microsoft documents this as a known limitation; protect the machines with network controls instead.", "exclusionRisk": "medium" }, + { "appId": "00000002-0000-0ff1-ce00-000000000000", "displayName": "Office 365 Exchange Online", "purpose": "Email, calendars and contacts, used by Outlook, mobile mail apps and third-party mail clients.", "commonExclusionReason": "Exempted to keep older mail protocols working. Blocking the older sign-in methods is the better answer than exempting mail.", "exclusionRisk": "high" }, + { "appId": "00000003-0000-0ff1-ce00-000000000000", "displayName": "Office 365 SharePoint Online", "purpose": "SharePoint and OneDrive, which hold all Microsoft 365 files, team sites and document libraries.", "commonExclusionReason": "Exempted to keep file-sync clients or third-party integrations working. High risk, because it exposes every corporate document.", "exclusionRisk": "high" }, + { "appId": "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe", "displayName": "Microsoft Teams Services", "purpose": "Chat, meetings, calling and collaboration in Microsoft Teams.", "commonExclusionReason": "Exempted to avoid prompts during meetings or to keep meeting-room devices working. A managed-device requirement is usually the better answer.", "exclusionRisk": "high" }, + { "appId": "0000000a-0000-0000-c000-000000000000", "displayName": "Microsoft Intune", "purpose": "Device and app management, covering device compliance, app deployment and configuration.", "commonExclusionReason": "Exempted so devices can enroll before their compliance can be judged; without it, a device cannot enroll because it is not yet compliant.", "exclusionRisk": "medium" }, + { "appId": "d4ebce55-015a-49b5-a083-c84d1797ae8c", "displayName": "Microsoft Intune Enrollment", "purpose": "Handles device enrollment into Intune during Autopilot, personal-device enrollment and device registration.", "commonExclusionReason": "Exempted so devices can complete their first enrollment before compliance rules apply. A common and generally accepted exemption.", "exclusionRisk": "low" }, + { "appId": "0000000f-0000-0000-c000-000000000000", "displayName": "Microsoft Intune API", "purpose": "The management interface for Intune, used by admin tools, scripts and automation.", "commonExclusionReason": "Exempted so scripts and management tools can work with Intune. A dedicated automation identity with limited permissions is usually the better answer.", "exclusionRisk": "medium" }, + { "appId": "fc780465-2017-40d4-a0c5-307022471b92", "displayName": "WindowsDefenderATP", "purpose": "Microsoft Defender for Endpoint, which provides threat detection, investigation and vulnerability management.", "commonExclusionReason": "Exempted so Defender agents can report without a multifactor prompt. Generally low risk as machine-to-service traffic, but confirm it is the agent and not portal access.", "exclusionRisk": "medium" }, + { "appId": "ea890292-c8c8-4433-b5ea-b09d0668e1a6", "displayName": "Azure Credential Configuration Endpoint Service", "purpose": "Manages the federated credentials and certificates that automation identities such as build pipelines use to sign in without a secret.", "commonExclusionReason": "Exempted so that keyless sign-in from build and deployment pipelines keeps working.", "exclusionRisk": "low" }, + { "appId": "00000012-0000-0000-c000-000000000000", "displayName": "Microsoft Rights Management Services", "purpose": "Encrypts and protects documents and email through sensitivity labels in Office, Outlook and SharePoint.", "commonExclusionReason": "Exempted to avoid a multifactor prompt every time a protected document or email is opened, which otherwise breaks the reading experience.", "exclusionRisk": "low" }, + { "appId": "65d91a3d-ab74-42e6-8a2f-0add61688c74", "displayName": "Microsoft Approval Management", "purpose": "Runs approval workflows across Microsoft 365, including Teams, Power Automate and SharePoint approvals.", "commonExclusionReason": "Exempted so approval requests and responses flow without interruption. Blocking it can stop approval workflows from completing.", "exclusionRisk": "low" }, + { "appId": "1b912ec3-a9dd-4c4d-a53e-76aa7adb28d7", "displayName": "AADReporting", "purpose": "The reporting service behind sign-in logs, audit logs and usage reports.", "commonExclusionReason": "Exempted so monitoring tools and log collectors can pull reports without triggering a policy.", "exclusionRisk": "low" }, + { "appId": "00000007-0000-0000-c000-000000000000", "displayName": "Dataverse", "purpose": "The data platform behind Power Apps, Power Automate and Dynamics 365.", "commonExclusionReason": "Exempted so Power Platform apps and flows can reach their data. Dedicated automation identities are usually the better answer.", "exclusionRisk": "medium" }, + { "appId": "7df0a125-d3be-4c96-aa54-591f83ff541c", "displayName": "Microsoft Flow Service", "purpose": "Power Automate, which runs automated workflows across hundreds of services.", "commonExclusionReason": "Exempted so cloud flows can run on behalf of people without an interactive prompt. Flows that use personal connections fail if it is blocked.", "exclusionRisk": "medium" }, + { "appId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "displayName": "Microsoft Azure CLI", "purpose": "The Azure command-line tool used by engineers and administrators for scripting and automation.", "commonExclusionReason": "Exempted so scripts can sign in. It can reach a very wide range of services; automation should use a dedicated identity instead of an exemption.", "exclusionRisk": "high" }, + { "appId": "1950a258-227b-4e31-a9cf-717495945fc2", "displayName": "Microsoft Azure PowerShell", "purpose": "The Azure PowerShell tooling used for administration and automation, with the widest reach of any standard Microsoft client.", "commonExclusionReason": "Exempted so PowerShell automation can sign in. Very high risk given its reach; automation should use a dedicated identity instead.", "exclusionRisk": "critical" }, + { "appId": "1b730954-1685-4b74-9bfd-dac224a7b894", "displayName": "Azure Active Directory PowerShell", "purpose": "The older directory PowerShell tooling for managing users and groups, being replaced by Microsoft Graph PowerShell.", "commonExclusionReason": "Exempted for older automation scripts. Those scripts should move to Microsoft Graph PowerShell with a dedicated identity.", "exclusionRisk": "high" }, + { "appId": "aebc6443-996d-45c2-90f0-388ff96faa56", "displayName": "Visual Studio Code", "purpose": "The VS Code editor, whose Azure extensions use this identity to reach Azure resources, repositories and Copilot.", "commonExclusionReason": "Exempted so developers can use the Azure extensions. It supports multifactor authentication, so requiring it is preferable to an exemption.", "exclusionRisk": "medium" }, + { "appId": "cb1056e2-e479-49de-ae31-7812af012ed8", "displayName": "Microsoft Azure Active Directory Connect", "purpose": "Entra Connect, which synchronizes on-premises Active Directory with Microsoft Entra ID.", "commonExclusionReason": "Exempted so the synchronization service can run without an interactive prompt. Keep the exemption limited to the dedicated synchronization account.", "exclusionRisk": "medium" }, + { "appId": "dd762716-544d-4aeb-a526-687b73838a22", "displayName": "Microsoft Device Registration Client", "purpose": "Handles device registration and joining, needed for workplace join, hybrid join and Autopilot.", "commonExclusionReason": "Exempted so devices can register. Only a multifactor requirement applies to this service anyway; network and managed-device conditions are ignored by design.", "exclusionRisk": "medium" }, + { "appId": "de50c81f-5f80-4771-b66b-cebd28ccdfc1", "displayName": "Device Management Client", "purpose": "An internal Windows component for device management, with access to more services than any other application in a tenant.", "commonExclusionReason": "Exempted for device management flows. Extremely high risk because of its reach.", "exclusionRisk": "critical" }, + { "appId": "1b3c667f-cde3-4090-b60b-3d2abd0117f0", "displayName": "Windows Spotlight", "purpose": "Delivers lock-screen images, tips and suggestions on Windows devices.", "commonExclusionReason": "Exempted because the lock screen makes background calls that fail with a multifactor prompt. Low sensitivity, as it only fetches display content.", "exclusionRisk": "low" }, + { "appId": "826870f9-9fbb-4f23-81b8-3a957080dfa2", "displayName": "Security Copilot", "purpose": "Microsoft's security assistant for threat investigation, incident response and query writing.", "commonExclusionReason": "Exempted so analysts are not interrupted during incident response. A managed-device requirement is usually the better answer.", "exclusionRisk": "high" }, + { "appId": "01cb2876-7ebd-4aa4-9cc9-d28bd4d359a9", "displayName": "Device Registration Service", "purpose": "The backend service for device registration, join and Autopilot.", "commonExclusionReason": "Exempted because only a multifactor requirement applies to this service; network and managed-device conditions are ignored by design.", "exclusionRisk": "medium" } +] diff --git a/Config/SecuritySimulations/CAAnalysis/BypassApps.json b/Config/SecuritySimulations/CAAnalysis/BypassApps.json new file mode 100644 index 0000000000000..190013e333515 --- /dev/null +++ b/Config/SecuritySimulations/CAAnalysis/BypassApps.json @@ -0,0 +1,50 @@ +{ + "immuneResources": [ + { "resourceId": "26a4ae64-5862-427f-a9b0-044e62572a4f", "displayName": "Microsoft Intune Checkin" }, + { "resourceId": "04436913-cf0d-4d2a-9cc6-2ffe7f1d3d1c", "displayName": "Windows Notification Service" }, + { "resourceId": "0a5f63c0-b750-4f38-a71c-4fc0d58b89e2", "displayName": "Microsoft Mobile Application Management" }, + { "resourceId": "1f5530b3-261a-47a9-b357-ded261e17918", "displayName": "Azure Multi-Factor Auth Connector" }, + { "resourceId": "c2ada927-a9e2-4564-aae2-70775a2fa0af", "displayName": "OCaaS Client Interaction Service" }, + { "resourceId": "ff9ebd75-fe62-434a-a6ce-b3f0a8592eaf", "displayName": "Authenticator App" } + ], + "bypassApps": [ + { "appId": "04b07795-8ddb-461a-bbee-02f9e1bf7b46", "displayName": "Microsoft Azure CLI", "description": "The Azure command-line tool, with access to a very wide range of services. A public client that can sidestep Conditional Access." }, + { "appId": "1950a258-227b-4e31-a9cf-717495945fc2", "displayName": "Microsoft Azure PowerShell", "description": "The Azure PowerShell tooling, with the widest service reach of any standard Microsoft client." }, + { "appId": "1b730954-1685-4b74-9bfd-dac224a7b894", "displayName": "Azure Active Directory PowerShell", "description": "The older directory PowerShell tooling, with broad directory access." }, + { "appId": "cb1056e2-e479-49de-ae31-7812af012ed8", "displayName": "Microsoft Azure Active Directory Connect", "description": "Hybrid identity synchronization, with broad access." }, + { "appId": "aebc6443-996d-45c2-90f0-388ff96faa56", "displayName": "Visual Studio Code", "description": "The VS Code editor, whose Azure extensions reach Azure resources." }, + { "appId": "fc0f3af4-6835-4174-b806-f7db311fd2f3", "displayName": "Microsoft Intune Windows Agent", "description": "The Intune agent; device enrollment sidesteps a managed-device requirement." }, + { "appId": "dd762716-544d-4aeb-a526-687b73838a22", "displayName": "Microsoft Device Registration Client", "description": "Device registration; network conditions do not apply to it and only a multifactor requirement protects it." }, + { "appId": "de50c81f-5f80-4771-b66b-cebd28ccdfc1", "displayName": "Device Management Client", "description": "A device management component with access to more services than any other application." }, + { "appId": "a672d62c-fc7b-4e81-a576-e60dc46e951d", "displayName": "Microsoft Power Query for Excel", "description": "Data connection tooling for Excel, with broad access." }, + { "appId": "cf710c6e-dfcc-4fa8-a093-d47294e44c66", "displayName": "Azure Analysis Services Client", "description": "Client tooling for Azure Analysis Services." }, + { "appId": "c58637bb-e2e1-4312-8a00-04b5ffcd3403", "displayName": "SharePoint Online Client Extensibility", "description": "Extensibility client for SharePoint Online." }, + { "appId": "268761a2-03f3-40df-8a8b-c3db24145b6b", "displayName": "Universal Store Native Client", "description": "The Windows Store client." }, + { "appId": "1b3c667f-cde3-4090-b60b-3d2abd0117f0", "displayName": "Windows Spotlight", "description": "The Windows lock-screen content client." } + ], + "deviceRegistrationResource": { "resourceId": "01cb2876-7ebd-4aa4-9cc9-d28bd4d359a9", "displayName": "Device Registration Service" }, + "wellKnownApps": [ + { "appId": "00000003-0000-0000-c000-000000000000", "displayName": "Microsoft Graph" }, + { "appId": "00000002-0000-0000-c000-000000000000", "displayName": "Azure AD Graph (Legacy)" }, + { "appId": "797f4846-ba00-4fd7-ba43-dac1f8f63013", "displayName": "Azure Resource Manager" }, + { "appId": "0000000a-0000-0000-c000-000000000000", "displayName": "Microsoft Intune" }, + { "appId": "00000002-0000-0ff1-ce00-000000000000", "displayName": "Office 365 Exchange Online" }, + { "appId": "00000003-0000-0ff1-ce00-000000000000", "displayName": "Office 365 SharePoint Online" }, + { "appId": "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe", "displayName": "Microsoft Teams Services" }, + { "appId": "fc780465-2017-40d4-a0c5-307022471b92", "displayName": "WindowsDefenderATP" }, + { "appId": "826870f9-9fbb-4f23-81b8-3a957080dfa2", "displayName": "Security Copilot" }, + { "appId": "00000007-0000-0000-c000-000000000000", "displayName": "Dataverse" }, + { "appId": "7df0a125-d3be-4c96-aa54-591f83ff541c", "displayName": "Microsoft Flow Service" } + ], + "highValueApps": [ + { "appId": "797f4846-ba00-4fd7-ba43-dac1f8f63013", "name": "Azure Management", "description": "the Azure portal, command line and automation tooling", "risk": "critical" }, + { "appId": "00000002-0000-0ff1-ce00-000000000000", "name": "Office 365 Exchange Online", "description": "email, calendars and contacts", "risk": "high" }, + { "appId": "00000003-0000-0ff1-ce00-000000000000", "name": "Office 365 SharePoint Online", "description": "SharePoint and OneDrive files", "risk": "high" }, + { "appId": "00000003-0000-0000-c000-000000000000", "name": "Microsoft Graph", "description": "programmatic access to all Microsoft 365 data", "risk": "critical" }, + { "appId": "c44b4083-3bb0-49c1-b47d-974e53cbdf3c", "name": "Azure Portal", "description": "web-based Azure management", "risk": "critical" } + ], + "appGroupAliases": { + "office365": { "displayName": "Office 365 (app group)", "purpose": "Built-in Conditional Access application group covering the core Office 365 services (Exchange Online, SharePoint Online, Teams, and related apps)." }, + "microsoftadminportals": { "displayName": "Microsoft Admin Portals (app group)", "purpose": "Built-in Conditional Access application group covering the Microsoft admin portals (Microsoft Entra admin center, Microsoft 365 admin center, Azure portal, and others)." } + } +} diff --git a/Config/SecuritySimulations/CAAnalysis/CoverageControls.json b/Config/SecuritySimulations/CAAnalysis/CoverageControls.json new file mode 100644 index 0000000000000..6d254a70e6653 --- /dev/null +++ b/Config/SecuritySimulations/CAAnalysis/CoverageControls.json @@ -0,0 +1,10 @@ +{ + "RequireMfa": { "label": "Multifactor authentication", "description": "Without it, a stolen or guessed password is enough to sign in as one of them." }, + "PhishingResistantMfa": { "label": "Phishing-resistant authentication", "description": "Without it, their multifactor prompt can be captured by a convincing phishing page." }, + "RequireCompliantDevice": { "label": "Managed device", "description": "Without it, they can sign in from any device, including unmanaged or infected ones." }, + "BlockLegacyAuth": { "label": "Legacy authentication blocked", "description": "Without it, older sign-in methods that cannot use multifactor authentication remain available to them." }, + "SignInRisk": { "label": "Sign-in risk response", "description": "Without it, a sign-in that Microsoft rates as suspicious goes through without any extra check." }, + "UserRisk": { "label": "User risk response", "description": "Without it, an account that Microsoft rates as likely compromised keeps working until someone notices." }, + "SessionControls": { "label": "Session limits", "description": "Without it, a session on a shared or lost device stays valid indefinitely." }, + "BlockCountries": { "label": "Location restrictions", "description": "Without it, sign-ins are accepted from any country or network." } +} diff --git a/Config/SecuritySimulations/CAAnalysis/FociFamilies.json b/Config/SecuritySimulations/CAAnalysis/FociFamilies.json new file mode 100644 index 0000000000000..a5f15b4f1d271 --- /dev/null +++ b/Config/SecuritySimulations/CAAnalysis/FociFamilies.json @@ -0,0 +1,47 @@ +[ + { "appId": "1fec8e78-bce4-4aaf-ab1b-5451cc387264", "displayName": "Microsoft Teams" }, + { "appId": "d3590ed6-52b3-4102-aeff-aad2292ab01c", "displayName": "Microsoft Office" }, + { "appId": "27922004-5251-4030-b22d-91ecd9a37ea4", "displayName": "Outlook Mobile" }, + { "appId": "4e291c71-d680-4d0e-9640-0a3358e31177", "displayName": "PowerApps" }, + { "appId": "d326c1ce-6cc6-4de2-bebc-4591e5e13ef0", "displayName": "SharePoint" }, + { "appId": "ab9b8c07-8f02-4f72-87fa-80105867a763", "displayName": "OneDrive SyncEngine" }, + { "appId": "af124e86-4e96-495a-b70a-90f90ab96707", "displayName": "OneDrive iOS App" }, + { "appId": "b26aadf8-566f-4478-926f-589f601d9c74", "displayName": "OneDrive" }, + { "appId": "c0d2a505-13b8-4ae0-aa9e-cddd5eab0b12", "displayName": "Microsoft Power BI" }, + { "appId": "4813382a-8fa7-425e-ab75-3b753aab3abb", "displayName": "Microsoft Authenticator App" }, + { "appId": "0ec893e0-5785-4de6-99da-4ed124e5296c", "displayName": "Microsoft 365 Copilot" }, + { "appId": "57fcbcfa-7cee-4eb1-8b25-12d2030b4ee0", "displayName": "Microsoft Flow Mobile" }, + { "appId": "66375f6b-983f-4c2c-9701-d680650f588f", "displayName": "Microsoft Planner" }, + { "appId": "9ba1a5c7-f17a-4de9-a1f1-6178c8d51223", "displayName": "Microsoft Intune Company Portal" }, + { "appId": "22098786-6e16-43cc-a27d-191a01a1e3b5", "displayName": "Microsoft To-Do client" }, + { "appId": "0922ef46-e1b9-4f7e-9134-9ad00547eb41", "displayName": "Loop" }, + { "appId": "26a7ee05-5602-4d76-a7ba-eae8b7b67941", "displayName": "Windows Search" }, + { "appId": "f44b1140-bc5e-48c6-8dc0-5cf5a53c0e34", "displayName": "Microsoft Edge" }, + { "appId": "e9c51622-460d-4d3d-952d-966a5b1da34c", "displayName": "Microsoft Edge" }, + { "appId": "872cd9fa-d31f-45e0-9eab-6e460a02d1f1", "displayName": "Visual Studio - Legacy" }, + { "appId": "cf36b471-5b44-428c-9ce7-313bf84528de", "displayName": "Microsoft Bing Search" }, + { "appId": "844cca35-0656-46ce-b636-13f48b0eecbd", "displayName": "Microsoft Stream Mobile Native" }, + { "appId": "87749df4-7ccf-48f8-aa87-704bad0e0e16", "displayName": "Microsoft Teams - Device Admin Agent" }, + { "appId": "a569458c-7f2b-45cb-bab9-b7dee514d112", "displayName": "Yammer iPhone" }, + { "appId": "e9cee14e-f26a-4349-886f-10048e3ef4b8", "displayName": "Yammer Android" }, + { "appId": "b87b6fc6-536c-411d-9005-110ee6db77dc", "displayName": "Yammer iPad" }, + { "appId": "c1c74fed-04c9-4704-80dc-9f79a2e515cb", "displayName": "Yammer Web" }, + { "appId": "a40d7d7d-59aa-447e-a655-679a4107e548", "displayName": "Accounts Control UI" }, + { "appId": "a670efe7-64b6-454f-9ae9-4f1cf27aba58", "displayName": "Microsoft Lists App on Android" }, + { "appId": "540d4ff4-b4c0-44c1-bd06-cab1782d582a", "displayName": "ODSP Mobile Lists App" }, + { "appId": "dd47d17a-3194-4d86-bfd5-c6ae6f5651e3", "displayName": "Microsoft Defender for Mobile" }, + { "appId": "f05ff7c9-f75a-4acd-a3b5-f4b6a870245d", "displayName": "SharePoint Android" }, + { "appId": "d7b530a4-7680-4c23-a8bf-c52c121d2e87", "displayName": "Microsoft Edge Enterprise New Tab Page" }, + { "appId": "e9b154d0-7658-433b-bb25-6b8e0a8a7c59", "displayName": "Outlook Lite" }, + { "appId": "cab96880-db5b-4e15-90a7-f3f1d62ffe39", "displayName": "Microsoft Defender Platform" }, + { "appId": "be1918be-3fe3-4be9-b32b-b542fc27f02e", "displayName": "M365 Compliance Drive Client" }, + { "appId": "8ec6bc83-69c8-4392-8f08-b3c986009232", "displayName": "Microsoft Teams-T4L" }, + { "appId": "eb20f3e3-3dce-4d2c-b721-ebb8d4414067", "displayName": "Managed Meeting Rooms" }, + { "appId": "14638111-3389-403d-b206-a6a71d9f8f16", "displayName": "Copilot App" }, + { "appId": "038ddad9-5bbe-4f64-b0cd-12434d1e633b", "displayName": "ZTNA Network Access Client" }, + { "appId": "760282b4-0cfc-4952-b467-c8e0298fee16", "displayName": "ZTNA Network Access Client -- Private" }, + { "appId": "d5e23a82-d7e1-4886-af25-27037a0fdc2a", "displayName": "ZTNA Network Access Client -- M365" }, + { "appId": "ca01d00c-bfd6-46d6-ae7d-be5b5267d037", "displayName": "ZTNA Policy Service Client" }, + { "appId": "cde6adac-58fd-4b78-8d6d-9beaf1b0d668", "displayName": "Global Secure Access Client" }, + { "appId": "00b41c95-dab0-4487-9791-b9d2c32c80f2", "displayName": "Office 365 Management" } +] diff --git a/Config/SecuritySimulations/CAAnalysis/MicrosoftGuidance.json b/Config/SecuritySimulations/CAAnalysis/MicrosoftGuidance.json new file mode 100644 index 0000000000000..14890ed7a57d5 --- /dev/null +++ b/Config/SecuritySimulations/CAAnalysis/MicrosoftGuidance.json @@ -0,0 +1,173 @@ +[ + { + "id": "TokenProtectionApps", + "title": "Token protection is applied to applications that do not support it", + "appliesWhen": "A policy turns on token protection.", + "requirement": "Token protection only works for Exchange Online, SharePoint Online, Teams, Azure Virtual Desktop, Windows 365 and Windows Cloud Login. Applying it more widely, including through the Office 365 application group, locks people out of tools that cannot support it.", + "severity": "high", + "docUrl": "https://learn.microsoft.com/entra/identity/conditional-access/concept-token-protection#deployment", + "remediation": "Limit the token-protection policy to the individual services that support it rather than a broad selection or the Office 365 application group." + }, + { + "id": "TokenProtectionPlatforms", + "title": "Token protection is applied beyond Windows desktop apps", + "appliesWhen": "A policy turns on token protection.", + "requirement": "Token protection works only on Windows devices and only for desktop and mobile apps. Applying it to other platforms or to web browsers locks out people who cannot meet it.", + "severity": "high", + "docUrl": "https://learn.microsoft.com/entra/identity/conditional-access/concept-token-protection#deployment", + "remediation": "Limit the token-protection policy to Windows devices and to desktop and mobile apps, and leave web browsers out of scope." + }, + { + "id": "TokenProtectionDevices", + "title": "Token protection does not exempt devices that cannot support it", + "appliesWhen": "A policy turns on token protection.", + "requirement": "Several device types cannot support token protection, including Surface Hub, Teams Rooms, Cloud PCs, virtual desktop hosts, self-deploying and bulk-enrolled devices and Azure virtual machines. Unless they are exempt, they are locked out.", + "severity": "high", + "docUrl": "https://learn.microsoft.com/entra/identity/conditional-access/concept-token-protection#known-limitations", + "remediation": "Exempt the unsupported device types from the token-protection policy through a device filter so they keep working." + }, + { + "id": "BreakGlassMissing", + "title": "Tenant-wide policy has no emergency-access exemption", + "appliesWhen": "A policy that applies to every user requires multifactor authentication, blocks access, or requires a managed device.", + "requirement": "A policy that applies to every user should always exempt at least two emergency-access accounts, so that a mistake in the policy or a service outage cannot lock the whole organization out.", + "severity": "critical", + "docUrl": "https://learn.microsoft.com/entra/identity/role-based-access-control/security-emergency-access", + "remediation": "Exempt two dedicated emergency-access accounts from this policy and alert on any sign-in by them." + }, + { + "id": "SurfaceHubMfa", + "title": "Surface Hub meeting rooms cannot meet this policy", + "appliesWhen": "A policy that applies to every user and every application asks for multifactor authentication, a managed device, an approved app or app protection.", + "requirement": "Surface Hub room accounts cannot complete multifactor authentication or meet device, app or password-change requirements. When a policy that applies to every user asks for any of these, the rooms stop signing in.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/surface-hub/conditional-access-for-surface-hub", + "remediation": "Exempt the Surface Hub room accounts, or a group containing them, from this policy." + }, + { + "id": "TeamsRoomsMfa", + "title": "Teams Rooms devices cannot meet this policy", + "appliesWhen": "A policy that applies to every user and every application requires multifactor authentication or an authentication strength.", + "requirement": "Teams Rooms on Windows cannot complete multifactor authentication and Android room devices cannot meet an authentication strength. When a policy that applies to every user asks for these, the rooms stop signing in.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/microsoftteams/rooms/supported-ca-and-compliance-policies", + "remediation": "Exempt the Teams Rooms accounts from multifactor requirements and protect them with a managed-device requirement instead." + }, + { + "id": "DeviceCodeTeamsDevices", + "title": "Blocking device-code sign-in breaks Teams device setup", + "appliesWhen": "A policy blocks the device-code sign-in flow.", + "requirement": "Teams phones, panels and Android room devices are signed in remotely through the device-code flow. Blocking that flow for everyone prevents these devices from being set up.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/microsoftteams/rooms/supported-ca-and-compliance-policies", + "remediation": "Exempt the Teams device accounts from the device-code block, or limit the block so it does not apply to Teams devices." + }, + { + "id": "SignInFrequencyTeamsRooms", + "title": "Sign-in frequency signs Teams Rooms devices out", + "appliesWhen": "A policy that applies to every user and every application sets a sign-in frequency.", + "requirement": "Teams Rooms, phones and panels cannot handle a sign-in frequency. Applying one to every user makes these devices sign out periodically and disrupts meetings.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/microsoftteams/rooms/supported-ca-and-compliance-policies", + "remediation": "Exempt the Teams Rooms and shared-device accounts from the sign-in frequency, or apply it to administrator roles only." + }, + { + "id": "DefenderMobileApps", + "title": "Defender mobile apps are caught by a blocking policy", + "appliesWhen": "A blocking policy covers every user and every application without exempting the Microsoft Defender mobile apps.", + "requirement": "The Microsoft Defender mobile app reports device health in the background. A blocking policy that covers every application can stop those reports, and devices then appear non-compliant for no real reason.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/defender-endpoint/mobile-resources-defender-endpoint#microsoft-defender-mobile-app-exclusion-from-conditional-access-ca-policies", + "remediation": "Exempt the two Microsoft Defender mobile applications from this policy so device health reporting keeps working." + }, + { + "id": "AzureVmSignInMfa", + "title": "Remote desktop to Azure virtual machines may not meet this policy", + "appliesWhen": "A policy that applies to every user and every application requires multifactor authentication or a managed device without exempting virtual machine sign-in.", + "requirement": "Remote desktop sign-ins to Azure virtual machines can only satisfy a multifactor requirement from a device that supports Windows Hello for Business or a security key, and Windows Server devices can never count as compliant. Without an exemption, administrators may be unable to connect.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/entra/identity/devices/howto-vm-sign-in-azure-ad-windows#missing-application", + "remediation": "Exempt the Azure virtual machine sign-in application from multifactor and device requirements unless every administrator connects with Windows Hello for Business or a security key." + }, + { + "id": "ContinuousAccessEvaluationDisabled", + "title": "Continuous access evaluation is switched off", + "appliesWhen": "A policy switches continuous access evaluation off.", + "requirement": "Continuous access evaluation ends a session the moment an account is disabled, its password changes or its location changes. With it switched off, sessions stay valid for up to an hour after such an event.", + "severity": "high", + "docUrl": "https://learn.microsoft.com/entra/identity/conditional-access/concept-continuous-access-evaluation", + "remediation": "Keep continuous access evaluation switched on unless a specific problem with it has been confirmed." + }, + { + "id": "SignInFrequencyIndividualServices", + "title": "Sign-in frequency on individual services breaks Teams devices", + "appliesWhen": "A policy sets a sign-in frequency on individual Microsoft 365 services rather than on all applications.", + "requirement": "A sign-in frequency applied to individual Microsoft 365 services rather than all applications can interrupt or stop the Teams device sign-in flow and is not supported by Microsoft.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/microsoftteams/rooms/supported-ca-and-compliance-policies", + "remediation": "Apply the sign-in frequency to all applications or to the Microsoft admin portals rather than to individual services." + }, + { + "id": "ResilienceDefaultsDisabled", + "title": "Users lose access during a Microsoft outage", + "appliesWhen": "A policy switches resilience defaults off.", + "requirement": "With resilience defaults switched off, anyone whose session expires during a Microsoft sign-in service outage is denied access until the service recovers.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/entra/identity/conditional-access/resilience-defaults", + "remediation": "Keep resilience defaults switched on unless strict real-time evaluation is a firm requirement." + }, + { + "id": "AllResourcesAppExclusion", + "title": "Basic directory permissions need their own coverage", + "appliesWhen": "A policy covers every application but exempts some.", + "requirement": "Microsoft used to exempt basic profile and directory permissions from a tenant-wide policy that had any application exemption. That exemption has been removed and those permissions are now enforced through the directory service itself, so a policy should cover that service to keep the baseline intact.", + "severity": "high", + "docUrl": "https://learn.microsoft.com/entra/identity/conditional-access/concept-conditional-access-cloud-apps#conditional-access-behavior-when-an-all-resources-policy-has-an-app-exclusion", + "remediation": "Cover the directory service with a policy that requires multifactor authentication from all users, so basic permissions stay protected even where other applications are exempt." + }, + { + "id": "DirectorySyncAccountMfa", + "title": "Directory synchronization exemption may no longer be needed", + "appliesWhen": "A multifactor policy for every user exempts the directory synchronization role.", + "requirement": "The synchronization account used to be exempt from multifactor requirements because it could not perform them. Recent versions of Entra Connect let the synchronization service sign in as an application instead, which removes the need for the exemption.", + "severity": "medium", + "docUrl": "https://learn.microsoft.com/entra/identity/hybrid/connect/reference-connect-version-history", + "remediation": "Upgrade Entra Connect to a version that supports application-based sign-in, switch to it, and remove the synchronization role exemption from multifactor policies." + }, + { + "id": "ExternalAuthMethodGuests", + "title": "External authentication provider may block guests and partners", + "appliesWhen": "A policy for every user or for administrators relies on an external authentication provider.", + "requirement": "When a policy requires an external authentication provider such as Duo or RSA, guests, partners and service providers cannot meet it because they are not enrolled with that provider. They are locked out even though Microsoft's own multifactor authentication would have been enough.", + "severity": "high", + "docUrl": "https://learn.microsoft.com/entra/identity/authentication/how-to-authentication-external-method-manage", + "remediation": "Limit the external-provider requirement to internal users enrolled with it, and let guests and partners meet the policy with Microsoft's own multifactor authentication." + }, + { + "id": "ApprovedClientAppRetirement", + "title": "Approved-client-app requirement is being retired", + "appliesWhen": "A policy relies on the approved-client-app requirement.", + "requirement": "Microsoft is retiring the approved-client-app requirement. Policies that rely on it must move to the app protection policy requirement, or accept either of the two, to keep protecting data in mobile apps.", + "severity": "critical", + "docUrl": "https://learn.microsoft.com/entra/identity/conditional-access/migrate-approved-client-app", + "remediation": "Replace the approved-client-app requirement with the app protection policy requirement, accepting either during the transition if needed." + }, + { + "id": "UserRiskPasswordChangeRetired", + "title": "Risky users are asked for a password change that not everyone can complete", + "appliesWhen": "A user risk policy relies on the password-change requirement.", + "requirement": "The password-change requirement for risky users has been retired. People who sign in without a password cannot complete it and stay locked out once flagged. The newer risk-remediation requirement chooses the right recovery path for each person.", + "severity": "high", + "docUrl": "https://learn.microsoft.com/entra/id-protection/concept-identity-protection-policies", + "remediation": "Replace the password-change requirement with the risk-remediation requirement." + }, + { + "id": "UserRiskRemediationExternalProvider", + "title": "Risk remediation cannot be completed by users of an external provider", + "appliesWhen": "A user risk policy pairs risk remediation with an authentication strength.", + "requirement": "When risk remediation is paired with an authentication strength, people who use an external provider such as Duo, Okta or Ping cannot complete it, because authentication strengths do not accept external providers. Without a companion policy that accepts plain multifactor authentication for them, they stay locked out.", + "severity": "high", + "docUrl": "https://learn.microsoft.com/entra/id-protection/concept-identity-protection-policies", + "remediation": "Add a companion risk policy for the users of the external provider that requires plain multifactor authentication together with risk remediation, and keep the stronger requirement for everyone else." + } +] diff --git a/Config/SecuritySimulations/CAAnalysis/Reference.json b/Config/SecuritySimulations/CAAnalysis/Reference.json new file mode 100644 index 0000000000000..a57fda0a8dfe1 --- /dev/null +++ b/Config/SecuritySimulations/CAAnalysis/Reference.json @@ -0,0 +1,93 @@ +{ + "registerDeviceAction": "urn:user:registerdevice", + "registerSecurityInfoAction": "urn:user:registersecurityinfo", + "windowsAzureAdResource": "00000002-0000-0000-c000-000000000000", + "legacyMfaTrustedIpsLocation": "00000000-0000-0000-0000-000000000000", + "directorySyncRoleTemplateId": "d29b2b05-8046-44ba-8758-1e26182fcf32", + "equivalentStrengthGroups": { + "compliantDevice": "DeviceTrust", + "domainJoinedDevice": "DeviceTrust", + "approvedApplication": "AppProtection", + "compliantApplication": "AppProtection" + }, + "grantControlLabels": { + "mfa": "multifactor authentication", + "compliantDevice": "a compliant device", + "domainJoinedDevice": "a Microsoft Entra hybrid joined device", + "approvedApplication": "an approved client app", + "compliantApplication": "an app protection policy", + "passwordChange": "a password change" + }, + "guestTypeLabels": { + "internalGuest": "Internal guest users", + "b2bCollaborationGuest": "Collaboration guests", + "b2bCollaborationMember": "Collaboration members", + "b2bDirectConnectUser": "Direct connect users", + "otherExternalUser": "Other external users", + "serviceProvider": "Service provider users" + }, + "guestTypeOrder": ["internalGuest", "b2bCollaborationGuest", "b2bCollaborationMember", "b2bDirectConnectUser", "otherExternalUser", "serviceProvider"], + "managedPolicyPatterns": [ + { "keyword": "block legacy authentication" }, + { "keyword": "block device code flow" }, + { "keyword": "multifactor authentication for admins" }, + { "keyword": "multifactor authentication for all users" }, + { "keyword": "multifactor authentication for per-user" }, + { "keyword": "reauthentication for risky sign-ins" }, + { "keyword": "block access for high-risk users" }, + { "keyword": "block all high risk agents" } + ], + "phishingResistant": { + "builtInStrengthId": "00000000-0000-0000-0000-000000000004", + "methodTokens": ["fido2", "windowshelloforbusiness", "x509certificatemultifactor", "x509certificatesinglefactor", "deviceboundpasskey", "hardwareoath"], + "nameRegex": "phishing.?resistant|fido2|windows hello|certificate.?based", + "policyNameRegex": "phishing.?resistant" + }, + "servicePlanIds": { + "entraIdP1": "41781fb2-bc02-4b7c-bd55-b576c07bb09d", + "entraIdP2": "eec0eb4f-6444-4f95-aba0-50c24d67f998", + "intunePlan1": "c1ec4a95-1f05-45b3-a911-aa3fa01094f5", + "workloadIdPremiumP1": "84c289f0-efcb-486f-8581-07f44fc9efad", + "workloadIdPremiumP2": "7dc0e92d-bf15-401d-907e-0884efe7c760" + }, + "guidanceAppIds": { + "exchangeOnline": "00000002-0000-0ff1-ce00-000000000000", + "sharePointOnline": "00000003-0000-0ff1-ce00-000000000000", + "teamsService": "cc15fd57-2c6c-4117-a88c-83b1d56b4bbe", + "office365Group": "Office365", + "azureVirtualDesktop": "9cdead84-a844-4324-93f2-b2e6bb768d07", + "windows365": "0af06dc6-e4b5-4f28-818e-e78e62d137a5", + "defenderAtpXplat": "a0e84e36-b067-4d5c-ab4a-3db38e598ae2", + "defenderTvm": "e724aa31-0f56-4018-b8be-f8cb82ca1196", + "windowsCloudLogin": "372140e0-b3b7-4226-8ef9-d57986796201" + }, + "tokenProtectionDeviceFilterPatterns": [ + { "pattern": "cloudpc", "label": "Cloud PCs" }, + { "pattern": "azurevirtualdesktop", "label": "Azure Virtual Desktop" }, + { "pattern": "powerapautomate", "label": "Power Automate hosted machines" }, + { "pattern": "autopilot", "label": "Autopilot self-deploying devices" }, + { "pattern": "securevm", "label": "Azure virtual machines" } + ], + "templates": { + "mfaAllUsers": "Require multifactor authentication for all users", + "mfaAllAdmins": "Require multifactor authentication for administrators", + "mfaGuests": "Require multifactor authentication for guest access", + "mfaB2BGuest": "Require multifactor authentication for guest access", + "mfaMixedGuests": "Require multifactor authentication for guest access", + "phishingResistantAdmins": "Require phishing-resistant multifactor authentication for administrators", + "registerSecurityInfo": "Secure security information registration", + "signInRisk": "Require multifactor authentication for medium and high sign-in risk", + "userRisk": "Require a secure password change for high user risk", + "blockCountries": "Block sign-ins from countries outside the allowed list", + "blockLegacyAuth": "Block legacy authentication", + "blockUnsupportedPlatforms": "Block unsupported device platforms", + "blockDeviceCode": "Block device code flow sign-ins", + "blockHighRiskUsers": "Block high-risk users", + "blockHighRiskSignIns": "Block high-risk sign-ins", + "windowsAzureAdBaselineScopes": "Require multifactor authentication for basic directory access", + "riskRemediationHigh": "Require risk remediation for high user risk", + "riskRemediationEam": "Require risk remediation for external authentication method users", + "appProtectionMobile": "Require an approved client app or app protection policy on mobile", + "requireCompliantDevice": "Require a compliant or Microsoft Entra hybrid joined device" + } +} diff --git a/Config/SecuritySimulations/CASituations.json b/Config/SecuritySimulations/CASituations.json new file mode 100644 index 0000000000000..0808686edaace --- /dev/null +++ b/Config/SecuritySimulations/CASituations.json @@ -0,0 +1,265 @@ +[ + { + "id": "AdminUnmanagedDevice", + "group": "Admin accounts", + "persona": "admin", + "title": "Global Admin · unmanaged device · known location", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "deviceInfo": { "isCompliant": false } }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "No compliant-device requirement for administrators", "fix": { "caTemplate": "Require a compliant device for administrators" } } + }, + { + "id": "AdminForeignCountry", + "group": "Admin accounts", + "persona": "admin", + "title": "Global Admin · sign-in from a foreign country", + "countryFromSelection": true, + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "country": "RU", "ipAddress": "45.132.1.20" }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "No location policy for administrators", "fix": { "caTemplate": "Block sign-ins from countries outside the allowed list" } } + }, + { + "id": "AdminHostingIp", + "group": "Admin accounts", + "persona": "admin", + "title": "Global Admin · sign-in from a hosting-provider address", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "country": "US", "ipAddress": "104.244.72.115" }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "Admin sign-ins from hosting-provider addresses are not blocked", "fix": { "caTemplate": "Block sign-ins from hosting-provider IP ranges" } } + }, + { + "id": "AdminDeviceCode", + "group": "Admin accounts", + "persona": "admin", + "title": "Global Admin · device-code flow", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "authenticationFlow": { "transferMethod": "deviceCodeFlow" } }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "Device code flow is not blocked for administrators", "fix": { "caTemplate": "Block device code flow sign-ins" } } + }, + { + "id": "AdminLegacyAuth", + "group": "Admin accounts", + "persona": "admin", + "title": "Global Admin · legacy authentication client", + "conditions": { "clientAppType": "other", "devicePlatform": "windows" }, + "attackerCanSatisfy": [], + "expected": "blocked", + "missingControl": { "text": "Legacy authentication is not blocked", "fix": { "caTemplate": "Block legacy authentication" } } + }, + { + "id": "AdminHighSignInRisk", + "group": "Admin accounts", + "persona": "admin", + "title": "Global Admin · high sign-in risk", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "signInRiskLevel": "high" }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "No sign-in risk policy for administrators", "fix": { "caTemplate": "Require multifactor authentication for medium and high sign-in risk" } } + }, + { + "id": "AdminMacOsUnmanaged", + "group": "Admin accounts", + "persona": "admin", + "title": "Global Admin · unmanaged macOS device", + "conditions": { "clientAppType": "browser", "devicePlatform": "macOS", "deviceInfo": { "isCompliant": false } }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "No compliant-device requirement for administrators", "fix": { "caTemplate": "Require a compliant device for administrators" } } + }, + { + "id": "AdminDesktopClientUnmanaged", + "group": "Admin accounts", + "persona": "admin", + "title": "Global Admin · desktop client · unmanaged device", + "conditions": { "clientAppType": "mobileAppsAndDesktopClients", "devicePlatform": "windows", "deviceInfo": { "isCompliant": false } }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "No compliant-device requirement for administrators", "fix": { "caTemplate": "Require a compliant device for administrators" } } + }, + { + "id": "UserUnmanagedDevice", + "group": "Standard users", + "persona": "user", + "title": "Standard user · unmanaged device · browser", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "deviceInfo": { "isCompliant": false } }, + "attackerCanSatisfy": ["mfa"], + "expected": "mfa", + "missingControl": { "text": "No compliant-device requirement for standard users", "fix": { "caTemplate": "Require a compliant or Microsoft Entra hybrid joined device" } } + }, + { + "id": "UserForeignCountry", + "group": "Standard users", + "persona": "user", + "title": "Standard user · sign-in from a foreign country", + "countryFromSelection": true, + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "country": "CN", "ipAddress": "223.5.5.5" }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "No location policy for standard users", "fix": { "caTemplate": "Block sign-ins from countries outside the allowed list" } } + }, + { + "id": "UserDeviceCode", + "group": "Standard users", + "persona": "user", + "title": "Standard user · device-code flow", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "authenticationFlow": { "transferMethod": "deviceCodeFlow" } }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "Device code flow is not blocked", "fix": { "caTemplate": "Block device code flow sign-ins" } } + }, + { + "id": "UserLegacyActiveSync", + "group": "Standard users", + "persona": "user", + "title": "Standard user · legacy Exchange ActiveSync", + "conditions": { "clientAppType": "exchangeActiveSync", "devicePlatform": "android" }, + "attackerCanSatisfy": [], + "expected": "blocked", + "missingControl": { "text": "Legacy authentication is not blocked", "fix": { "caTemplate": "Block legacy authentication" } } + }, + { + "id": "UserLegacyOther", + "group": "Standard users", + "persona": "user", + "title": "Standard user · other legacy client", + "conditions": { "clientAppType": "other", "devicePlatform": "windows" }, + "attackerCanSatisfy": [], + "expected": "blocked", + "missingControl": { "text": "Legacy authentication is not blocked", "fix": { "caTemplate": "Block legacy authentication" } } + }, + { + "id": "UserHighSignInRisk", + "group": "Standard users", + "persona": "user", + "title": "Standard user · high sign-in risk", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "signInRiskLevel": "high" }, + "attackerCanSatisfy": [], + "expected": "blocked", + "missingControl": { "text": "No sign-in risk policy", "fix": { "caTemplate": "Require multifactor authentication for medium and high sign-in risk" } } + }, + { + "id": "UserHighUserRisk", + "group": "Standard users", + "persona": "user", + "title": "Standard user · high user risk", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "userRiskLevel": "high" }, + "attackerCanSatisfy": [], + "expected": "blocked", + "missingControl": { "text": "No user risk policy", "fix": { "caTemplate": "Require a secure password change for high user risk" } } + }, + { + "id": "UserElevatedInsiderRisk", + "group": "Standard users", + "persona": "user", + "title": "Standard user · elevated insider risk", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "insiderRiskLevel": "elevated" }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "No insider-risk policy", "fix": { "caTemplate": "Limit access for elevated insider risk" } } + }, + { + "id": "UserAndroidUnmanaged", + "group": "Standard users", + "persona": "user", + "title": "Standard user · unmanaged Android device", + "conditions": { "clientAppType": "browser", "devicePlatform": "android", "deviceInfo": { "isCompliant": false } }, + "attackerCanSatisfy": ["mfa"], + "expected": "mfa", + "missingControl": { "text": "No app protection or compliant-device requirement on mobile", "fix": { "caTemplate": "Require an approved client app or app protection policy on mobile" } } + }, + { + "id": "UserIosUnmanaged", + "group": "Standard users", + "persona": "user", + "title": "Standard user · unmanaged iOS device", + "conditions": { "clientAppType": "browser", "devicePlatform": "iOS", "deviceInfo": { "isCompliant": false } }, + "attackerCanSatisfy": ["mfa"], + "expected": "mfa", + "missingControl": { "text": "No app protection or compliant-device requirement on mobile", "fix": { "caTemplate": "Require an approved client app or app protection policy on mobile" } } + }, + { + "id": "UserDesktopClientUnmanaged", + "group": "Standard users", + "persona": "user", + "title": "Standard user · desktop client · unmanaged device", + "conditions": { "clientAppType": "mobileAppsAndDesktopClients", "devicePlatform": "windows", "deviceInfo": { "isCompliant": false } }, + "attackerCanSatisfy": ["mfa"], + "expected": "mfa", + "missingControl": { "text": "No compliant-device requirement for standard users", "fix": { "caTemplate": "Require a compliant or Microsoft Entra hybrid joined device" } } + }, + { + "id": "GuestBrowserAccess", + "group": "Guests", + "persona": "guest", + "title": "Guest · browser access · known location", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows" }, + "attackerCanSatisfy": [], + "expected": "mfa", + "missingControl": { "text": "Guest access does not require MFA", "fix": { "caTemplate": "Require multifactor authentication for guest access" } } + }, + { + "id": "GuestForeignCountry", + "group": "Guests", + "persona": "guest", + "title": "Guest · sign-in from a foreign country", + "countryFromSelection": true, + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "country": "RU", "ipAddress": "45.132.1.20" }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "No location policy for guests", "fix": { "caTemplate": "Block sign-ins from countries outside the allowed list" } } + }, + { + "id": "GuestLegacyAuth", + "group": "Guests", + "persona": "guest", + "title": "Guest · legacy authentication client", + "conditions": { "clientAppType": "other", "devicePlatform": "windows" }, + "attackerCanSatisfy": [], + "expected": "blocked", + "missingControl": { "text": "Legacy authentication is not blocked", "fix": { "caTemplate": "Block legacy authentication" } } + }, + { + "id": "GuestUnmanagedDevice", + "group": "Guests", + "persona": "guest", + "title": "Guest · unmanaged device · browser", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "deviceInfo": { "isCompliant": false } }, + "attackerCanSatisfy": [], + "expected": "mfa", + "missingControl": { "text": "Guest access does not require MFA or a compliant device", "fix": { "caTemplate": "Require multifactor authentication for guest access" } } + }, + { + "id": "GuestDeviceCode", + "group": "Guests", + "persona": "guest", + "title": "Guest · device-code flow", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "authenticationFlow": { "transferMethod": "deviceCodeFlow" } }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "Device code flow is not blocked", "fix": { "caTemplate": "Block device code flow sign-ins" } } + }, + { + "id": "GuestHighSignInRisk", + "group": "Guests", + "persona": "guest", + "title": "Guest · high sign-in risk", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "signInRiskLevel": "high" }, + "attackerCanSatisfy": [], + "expected": "blocked", + "missingControl": { "text": "No sign-in risk policy for guests", "fix": { "caTemplate": "Require multifactor authentication for medium and high sign-in risk" } } + }, + { + "id": "GuestAuthTransfer", + "group": "Guests", + "persona": "guest", + "title": "Guest · authentication transfer", + "conditions": { "clientAppType": "browser", "devicePlatform": "windows", "authenticationFlow": { "transferMethod": "authenticationTransfer" } }, + "attackerCanSatisfy": ["mfa"], + "expected": "blocked", + "missingControl": { "text": "Authentication transfer is not blocked", "fix": { "caTemplate": "Block authentication transfer" } } + } +] diff --git a/Config/SharePoint.BPATemplate.json b/Config/SharePoint.BPATemplate.json deleted file mode 100644 index fd279d368fdac..0000000000000 --- a/Config/SharePoint.BPATemplate.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "name": "CIPP SharePoint Report v1.0 - Table view", - "style": "Table", - "Fields": [ - { - "name": "SharepointSettings", - "API": "Graph", - "URL": "https://graph.microsoft.com/beta/admin/sharepoint/settings", - "Parameters": { - "asApp": "True" - }, - "ExtractFields": [ - "sharingCapability", - "isMacSyncAppEnabled", - "isResharingByExternalUsersEnabled", - "isUnmanagedSyncAppForTenantRestricted", - "isSiteCreationEnabled", - "deletedUserPersonalSiteRetentionPeriodInDays" - ], - "StoreAs": "JSON", - "FrontendFields": [ - { - "name": "Sharing capability", - "value": "SharepointSettings.sharingCapability", - "formatter": "string" - }, - { - "name": "Mac Sync Enabled", - "value": "SharepointSettings.isMacSyncAppEnabled", - "formatter": "warnBool" - }, - { - "name": "Resharing by external users", - "value": "SharepointSettings.isResharingByExternalUsersEnabled", - "formatter": "bool" - }, - { - "name": "Allow users to sync from unmanaged devices", - "value": "SharepointSettings.isUnmanagedSyncAppForTenantRestricted", - "formatter": "reverseBool" - }, - { - "name": "Site creation by standard users enabled", - "value": "SharepointSettings.isSiteCreationEnabled", - "formatter": "bool" - }, - { - "name": "Deleted user data rention(days)", - "value": "SharepointSettings.deletedUserPersonalSiteRetentionPeriodInDays", - "formatter": "string" - } - ] - }, - { - "name": "WebtimeOut", - "API": "Graph", - "URL": "https://graph.microsoft.com/beta/policies/activityBasedTimeoutPolicies", - "ExtractFields": ["definition"], - "StoreAs": "bool", - "where": "$_.definition -like '*WebSessionIdleTimeout*'", - "FrontendFields": [ - { - "name": "Web Time-Out enabled", - "value": "WebtimeOut", - "formatter": "bool" - } - ] - } - ] -} diff --git a/Config/StandardsTable.BPATemplate.json b/Config/StandardsTable.BPATemplate.json deleted file mode 100644 index 858b1b462fca1..0000000000000 --- a/Config/StandardsTable.BPATemplate.json +++ /dev/null @@ -1,534 +0,0 @@ -{ - "name": "CIPP Standards v1.0 - Table view", - "style": "Table", - "Fields": [ - { - "name": "ActivityBasedTimeout", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "ActivityBasedTimeout", - "value": "ActivityBasedTimeout" - } - ] - }, - { - "name": "softwareOath", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "softwareOath", - "value": "softwareOath" - } - ] - }, - { - "name": "MSAuthenticator", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "MSAuthenticator", - "value": "MSAuthenticator" - } - ] - }, - { - "name": "AnonReport", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "AnonReport", - "value": "AnonReport" - } - ] - }, - { - "name": "AuditLog", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "AuditLog", - "value": "AuditLog" - } - ] - }, - { - "name": "AutoExpandingArchive", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "AutoExpandingArchive", - "value": "AutoExpandingArchive" - } - ] - }, - { - "name": "DelegateSentItems", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "DelegateSentItems", - "value": "DelegateSentItems" - } - ] - }, - { - "name": "DeletedUserRentention", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DeletedUserRentention", - "value": "DeletedUserRentention" - } - ] - }, - { - "name": "DisableBasicAuthSMTP", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DisableBasicAuthSMTP", - "value": "DisableBasicAuthSMTP" - } - ] - }, - { - "name": "DisableGuestDirectory", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DisableGuestDirectory", - "value": "DisableGuestDirectory" - } - ] - }, - { - "name": "DisableGuests", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "DisableGuests", - "value": "DisableGuests" - } - ] - }, - { - "name": "DisableM365GroupUsers", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DisableM365GroupUsers", - "value": "DisableM365GroupUsers" - } - ] - }, - { - "name": "MacSync", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "MacSync", - "value": "MacSync" - } - ] - }, - { - "name": "DisableReshare", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DisableReshare", - "value": "DisableReshare" - } - ] - }, - { - "name": "DisableSecurityGroupUsers", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DisableSecurityGroupUsers", - "value": "DisableSecurityGroupUsers" - } - ] - }, - { - "name": "DisableSharedMailbox", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "DisableSharedMailbox", - "value": "DisableSharedMailbox" - } - ] - }, - { - "name": "DisableTenantCreation", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DisableTenantCreation", - "value": "DisableTenantCreation" - } - ] - }, - { - "name": "DisableUserSiteCreate", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DisableUserSiteCreate", - "value": "DisableUserSiteCreate" - } - ] - }, - { - "name": "DisableViva", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "DisableViva", - "value": "DisableViva" - } - ] - }, - { - "name": "EnableAppConsentAdminRequests", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "EnableAppConsentAdminRequests", - "value": "EnableAppConsentAdminRequests" - } - ] - }, - { - "name": "EnableFIDO2", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "EnableFIDO2", - "value": "EnableFIDO2" - } - ] - }, - { - "name": "EnableOnlineArchiving", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "EnableOnlineArchiving", - "value": "EnableOnlineArchiving" - } - ] - }, - { - "name": "ExcludedfileExt", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "ExcludedfileExt", - "value": "ExcludedfileExt" - } - ] - }, - { - "name": "intuneDeviceReg", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "intuneDeviceReg", - "value": "intuneDeviceReg" - } - ] - }, - { - "name": "intuneDeviceRetirementDays", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "intuneDeviceRetirementDays", - "value": "intuneDeviceRetirementDays" - } - ] - }, - { - "name": "intuneRequireMFA", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "intuneRequireMFA", - "value": "intuneRequireMFA" - } - ] - }, - { - "name": "laps", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "laps", - "value": "laps" - } - ] - }, - { - "name": "MailContacts", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "MailContacts", - "value": "MailContacts" - } - ] - }, - { - "name": "NudgeMFA", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "NudgeMFA", - "value": "NudgeMFA" - } - ] - }, - { - "name": "OauthConsent", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "OauthConsent", - "value": "OauthConsent" - } - ] - }, - { - "name": "OauthConsentLowSec", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "OauthConsentLowSec", - "value": "OauthConsentLowSec" - } - ] - }, - { - "name": "OutboundSpamAlert", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "OutboundSpamAlert", - "value": "OutboundSpamAlert" - } - ] - }, - { - "name": "PasswordExpireDisabled", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "PasswordExpireDisabled", - "value": "PasswordExpireDisabled" - } - ] - }, - { - "name": "companionAppAllowedState", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "companionAppAllowedState", - "value": "companionAppAllowedState" - } - ] - }, - { - "name": "PWdisplayAppInformationRequiredState", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "PWdisplayAppInformationRequiredState", - "value": "PWdisplayAppInformationRequiredState" - } - ] - }, - { - "name": "DKIM", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "DKIM", - "value": "DKIM" - } - ] - }, - { - "name": "SecurityDefaults", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "warnBool", - "name": "SecurityDefaults", - "value": "SecurityDefaults" - } - ] - }, - { - "name": "SendFromAlias", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "bool", - "name": "SendFromAlias", - "value": "SendFromAlias" - } - ] - }, - { - "name": "SendReceiveLimit", - "StoreAs": "json", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "table", - "name": "SendReceiveLimit", - "value": "SendReceiveLimit" - } - ] - }, - { - "name": "sharingCapability", - "StoreAs": "string", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "string", - "name": "sharingCapability", - "value": "sharingCapability" - } - ] - }, - { - "name": "SpoofingWarnings", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "warnBool", - "name": "SpoofingWarnings", - "value": "SpoofingWarnings" - } - ] - }, - { - "name": "TemporaryAccessPass", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "warnBool", - "name": "TemporaryAccessPass", - "value": "TemporaryAccessPass" - } - ] - }, - { - "name": "unmanagedSync", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "warnBool", - "name": "unmanagedSync", - "value": "unmanagedSync" - } - ] - }, - { - "name": "UserSubmissionPolicy", - "StoreAs": "bool", - "UseExistingInfo": true, - "FrontendFields": [ - { - "formatter": "warnBool", - "name": "UserSubmissionPolicy", - "value": "UserSubmissionPolicy" - } - ] - } - ] -} diff --git a/Config/openapi.json b/Config/openapi.json index 7a37660c66dad..ce905e786a5c4 100644 --- a/Config/openapi.json +++ b/Config/openapi.json @@ -123,6 +123,9 @@ { "name": "Tenant > Administration > Tenant" }, + { + "name": "Tenant > AI" + }, { "name": "Tenant > Conditional" }, @@ -132,6 +135,9 @@ { "name": "Tenant > Reports" }, + { + "name": "Tenant > SecuritySimulations" + }, { "name": "Tenant > Standards" }, @@ -251,6 +257,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": { @@ -701,6 +714,10 @@ "description": { "type": "string" }, + "disableAlerts": { + "type": "boolean", + "description": "'Disable Alerts': deviations are still detected and shown, but no email, webhook or PSA notification fires for this baseline. Negative flag on purpose: rows saved before the column existed keep alerting, exactly right." + }, "disableScheduledRuns": { "type": "boolean", "description": "'Disable Scheduled Runs': the baseline only executes when an operator runs it. Negative flag on purpose: rows saved before the column existed default to scheduled, exactly right." @@ -712,6 +729,18 @@ }, "description": "The editor round-trips the tenant selector's own option objects ({label, value, type}) verbatim via assignedTo/excludedTo; the flat excludedTenants values keep the exclusion logic simple. Raw string values are accepted everywhere too." }, + "GitHub": { + "type": "object", + "properties": { + "FullName": { + "type": "string" + }, + "Message": { + "type": "string" + } + }, + "description": "Optional. Pushes the saved baseline to a GitHub template repository after the save: FullName is the repository (owner/repo), Message is the commit message. Not stored on the baseline." + }, "GUID": { "type": "string" }, @@ -754,13 +783,78 @@ "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 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.", "properties": { + "alertEmails": { + "x-cipp-field-source": "storage" + }, + "alertWebhookUrl": { + "x-cipp-field-source": "storage" + }, + "assignedTo": { + "x-cipp-field-source": "storage" + }, + "description": { + "x-cipp-field-source": "storage" + }, + "disableAlerts": { + "type": "boolean", + "x-cipp-field-source": "storage" + }, + "disableScheduledRuns": { + "type": "boolean", + "x-cipp-field-source": "storage" + }, + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "excludedTenants": { + "x-cipp-field-source": "storage" + }, + "excludedTo": { + "x-cipp-field-source": "storage" + }, + "LocalChanges": { + "type": "boolean", + "x-cipp-field-source": "storage" + }, "Metadata": { "x-cipp-field-source": "backend" }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, "Results": { "x-cipp-field-source": "backend" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "SHA": { + "x-cipp-field-source": "storage" + }, + "Source": { + "x-cipp-field-source": "storage" + }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, + "Stages": { + "x-cipp-field-source": "storage" + }, + "templateName": { + "x-cipp-field-source": "storage" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "updatedAt": { + "x-cipp-field-source": "storage" + }, + "updatedBy": { + "x-cipp-field-source": "storage" } } } @@ -788,60 +882,6 @@ ] } }, - "/api/AddBPATemplate": { - "post": { - "summary": "AddBPATemplate", - "operationId": "AddBPATemplate", - "tags": [ - "Tenant > Standards" - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - } - }, - "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": { - "Results": { - "x-cipp-field-source": "backend" - } - } - } - } - } - }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Tenant.BestPracticeAnalyser.ReadWrite" - } - }, "/api/AddCAPolicy": { "post": { "summary": "AddCAPolicy", @@ -1074,6 +1114,13 @@ "excludeGroup": { "type": "string" }, + "ExcludeGroupIds": { + "type": "string" + }, + "GroupIds": { + "type": "string", + "description": "Group ids from the deploy drawer's single-tenant picker. CustomGroup/excludeGroup still carry the display names for logging and as a fallback if the ids are ever dropped." + }, "InstallAsSystem": { "type": "string" }, @@ -2215,6 +2262,13 @@ "excludeGroup": { "type": "string" }, + "ExcludeGroupIds": { + "type": "string" + }, + "GroupIds": { + "type": "string", + "description": "Group ids from the deploy drawer's single-tenant picker. CustomGroup/excludeGroup still carry the display names for logging and as a fallback if the ids are ever dropped." + }, "IntuneBody": { "type": "string" }, @@ -4429,6 +4483,15 @@ "defaultUseRoles": { "type": "boolean" }, + "defaultVacationCAPolicy": { + "type": "string" + }, + "defaultVacationExcludeAuditAlerts": { + "type": "boolean" + }, + "defaultVacationMode": { + "type": "boolean" + }, "generateTAPByDefault": { "type": "boolean" }, @@ -4564,6 +4627,13 @@ "excludeGroup": { "type": "string" }, + "ExcludeGroupIds": { + "type": "string" + }, + "GroupIds": { + "type": "string", + "description": "Group ids from the deploy drawer's single-tenant picker. CustomGroup/excludeGroup still carry the display names for logging and as a fallback if the ids are ever dropped." + }, "PackageName": { "type": "string", "description": "Build the install/uninstall command lines for this tenant. Get-CIPPMSPAppInstallCommand resolves each param whether it is a per-tenant keyed value (interactive deploy) or a flat value / %CIPP variable% (Application Template deploy)." @@ -4770,6 +4840,13 @@ "excludeGroup": { "type": "string" }, + "ExcludeGroupIds": { + "type": "string" + }, + "GroupIds": { + "type": "string", + "description": "Group ids from the deploy drawer's single-tenant picker. CustomGroup/excludeGroup still carry the display names for logging and as a fallback if the ids are ever dropped." + }, "IntuneBody": { "type": "string", "description": "A template saved from an existing app carries the full body: reuse it, minus the read-only properties Graph rejects on create." @@ -4971,6 +5048,13 @@ "excludeGroup": { "type": "string" }, + "ExcludeGroupIds": { + "type": "string" + }, + "GroupIds": { + "type": "string", + "description": "Sent by the deploy drawer when a single tenant is selected and groups were picked by id. customGroup/excludeGroup still carry the display names for logging and as a fallback." + }, "RAWJson": { "type": "string" }, @@ -5850,7 +5934,7 @@ "Security > Compliance-SensitivityLabel" ], "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { @@ -5858,8 +5942,15 @@ "properties": { "PowerShellCommand": { "type": "string" + }, + "tenantFilter": { + "type": "string", + "description": "Encryption rights granted to the source tenant's own domain would follow the template into every other tenant. Swap them for %defaultdomain% so each deploy resolves to the target tenant instead." } }, + "required": [ + "tenantFilter" + ], "additionalProperties": true, "x-cipp-passthrough": true, "description": "This endpoint forwards the request body onward rather than reading a fixed set of fields. The properties listed here are the ones it is known to read; others may be accepted." @@ -5970,6 +6061,9 @@ "schema": { "type": "object", "properties": { + "isPublic": { + "type": "boolean" + }, "sensitivityLabel": { "type": "string" }, @@ -5977,7 +6071,12 @@ "type": "string" }, "siteDesign": { - "$ref": "#/components/schemas/LabelValue" + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "Optional. Only applies to the Team and Communication templates; omitted for TeamGroup." }, "siteName": { "type": "string" @@ -6048,6 +6147,9 @@ "items": { "type": "object", "properties": { + "isPublic": { + "type": "string" + }, "sensitivityLabel": { "type": "string" }, @@ -6055,7 +6157,8 @@ "type": "string" }, "siteDesign": { - "type": "string" + "type": "string", + "description": "Optional. Only applies to the Team and Communication templates; leave blank for TeamGroup." }, "siteName": { "type": "string" @@ -6289,9 +6392,25 @@ "createdAt": { "type": "string" }, + "GitHub": { + "type": "object", + "properties": { + "FullName": { + "type": "string" + }, + "Message": { + "type": "string" + } + }, + "description": "Optional. Pushes the saved template to a GitHub template repository after the save: FullName is the repository (owner/repo), Message is the commit message. Not stored on the template." + }, "GUID": { "type": "string" }, + "standards": { + "type": "string", + "description": "Drop standards toggled on but left with no instances (empty array) - e.g. a template standard whose last selection was removed in the editor, which leaves 'standards. = []' in the form. Persisting these produces phantom 'NOT FOUND' drift entries with no template to compare against." + }, "templateName": { "type": "string" }, @@ -6368,6 +6487,16 @@ "description": { "type": "string" }, + "excludeGroup": { + "type": "string" + }, + "ExcludeGroupIds": { + "type": "string" + }, + "GroupIds": { + "type": "string", + "description": "Group ids from the deploy drawer's single-tenant picker. CustomGroup/excludeGroup still carry the display names for logging and as a fallback if the ids are ever dropped." + }, "InstallAsSystem": { "type": "boolean", "description": "winGetAppInstallExperience only supports runAsAccount (no restart behavior). Default to system when the toggle is absent so older callers keep the previous behavior." @@ -7248,6 +7377,13 @@ "type": "string", "description": "Set-CIPPMailboxPermission takes a single level, so Full Access plus Send As means one task per level. They are separate Exchange operations anyway." }, + "sharePointSiteRole": { + "$ref": "#/components/schemas/LabelValue" + }, + "sharePointSites": { + "type": "string", + "description": "SharePoint site membership is scheduled instead of run inline: a freshly created account cannot be resolved on a site (ensureuser) until it has replicated to SharePoint." + }, "sherwebLicense": { "$ref": "#/components/schemas/LabelValue" }, @@ -7462,6 +7598,9 @@ "country": { "type": "string" }, + "defaultAttributes": { + "type": "string" + }, "defaultForTenant": { "type": "string" }, @@ -7536,6 +7675,13 @@ "sharedMailboxPermission": { "type": "string" }, + "sharePointSiteRole": { + "$ref": "#/components/schemas/LabelValue" + }, + "sharePointSites": { + "type": "string", + "description": "SharePoint sites new users are added to, with one role for all of them" + }, "state": { "type": "string" }, @@ -7648,6 +7794,16 @@ "enforceSignatureCheck": { "type": "boolean" }, + "excludeGroup": { + "type": "string" + }, + "ExcludeGroupIds": { + "type": "string" + }, + "GroupIds": { + "type": "string", + "description": "Group ids from the deploy drawer's single-tenant picker. CustomGroup/excludeGroup still carry the display names for logging and as a fallback if the ids are ever dropped." + }, "InstallAsSystem": { "type": "string" }, @@ -7721,45 +7877,6 @@ "x-cipp-any-tenant": true } }, - "/api/BestPracticeAnalyser_List": { - "get": { - "summary": "BestPracticeAnalyser_List", - "operationId": "BestPracticeAnalyser_List", - "tags": [ - "Tenant > Standards" - ], - "description": "Returns the cached Best Practice Analyser results for every tenant. The BPA is populated by a scheduled job, so this reads the last run rather than evaluating anything; if it has never run, a single placeholder row saying so is returned.", - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." - } - } - } - } - }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Tenant.BestPracticeAnalyser.Read", - "x-cipp-any-tenant": true - } - }, "/api/CreateSafeLinksPolicyTemplate": { "post": { "summary": "CreateSafeLinksPolicyTemplate", @@ -8512,6 +8629,9 @@ "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.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -8539,11 +8659,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -9323,6 +9447,15 @@ "defaultUseRoles": { "type": "boolean" }, + "defaultVacationCAPolicy": { + "type": "string" + }, + "defaultVacationExcludeAuditAlerts": { + "type": "boolean" + }, + "defaultVacationMode": { + "type": "boolean" + }, "generateTAPByDefault": { "type": "boolean" }, @@ -10382,6 +10515,9 @@ "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.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -10409,11 +10545,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -10785,6 +10925,9 @@ "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.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -10812,11 +10955,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -11433,7 +11580,7 @@ "tags": [ "CIPP > Settings" ], - "description": "Runs the CIPP deployment's self-diagnostics and returns the result. Type selects the check: 'Permissions' verifies the SAM application's Graph permissions, 'Tenants' tests access to each tenant, and 'GDAP' inspects the GDAP relationships and role mappings. Results are cached for an hour unless SkipCache is true.", + "description": "Runs the CIPP deployment's self-diagnostics and returns the result. Type selects the check: 'Permissions' verifies the SAM application's Graph permissions, 'Tenants' tests access to each tenant, and 'GDAP' inspects the GDAP relationships and role mappings. Results are cached for an hour and served from cache by default. Pass SkipCache to trigger a fresh run of the check; the run repopulates the cache and the response only acknowledges it started, so call again without SkipCache to read the updated results. For the 'Tenants' check, supplying TenantId returns just that tenant's cached result; add SkipCache to trigger a re-check of that single tenant.", "requestBody": { "required": false, "content": { @@ -11443,11 +11590,11 @@ "properties": { "SkipCache": { "type": "string", - "description": "Re-run the check instead of serving the cached result." + "description": "Trigger a fresh run of the check instead of serving the cached result. The run repopulates the cache and the response only acknowledges it, so a follow-up call without SkipCache is what returns the updated result." }, "TenantId": { "type": "string", - "description": "The tenant to (re)check for the 'Tenants' type. Query or body, for the same reason as Type." + "description": "The tenant to read for the 'Tenants' type: returns just that tenant's cached result. Pair it with SkipCache to trigger a re-check of that one tenant, then read again without SkipCache to get the refreshed result. Query or body, as with Type." }, "Type": { "type": "string", @@ -11462,7 +11609,7 @@ { "name": "SkipCache", "in": "query", - "description": "Re-run the check instead of serving the cached result.", + "description": "Trigger a fresh run of the check instead of serving the cached result. The run repopulates the cache and the response only acknowledges it, so a follow-up call without SkipCache is what returns the updated result.", "required": false, "schema": { "type": "string" @@ -11471,7 +11618,7 @@ { "name": "TenantId", "in": "query", - "description": "The tenant to (re)check for the 'Tenants' type. Query or body, for the same reason as Type.", + "description": "The tenant to read for the 'Tenants' type: returns just that tenant's cached result. Pair it with SkipCache to trigger a re-check of that one tenant, then read again without SkipCache to get the refreshed result. Query or body, as with Type.", "required": false, "schema": { "type": "string" @@ -12261,9 +12408,6 @@ "ExcludeUser": { "x-cipp-field-source": "storage" }, - "GraphErrorCount": { - "x-cipp-field-source": "storage" - }, "hasAutoExtend": { "x-cipp-field-source": "storage" }, @@ -12322,11 +12466,12 @@ }, "/api/ExecAddTrustedIP": { "post": { - "summary": "ExecAddTrustedIP", + "summary": "Adds IP addresses or ranges to CIPP's IP allow/block list.", "operationId": "ExecAddTrustedIP", "tags": [ "CIPP > Settings" ], + "description": "Sets each IP address or CIDR range (IPv4 or IPv6) to Trusted, Blocked or NotTrusted (neutral) for one tenant, or for every tenant with tenantfilter=AllTenants. A tenant entry overrides an AllTenants entry for the same range, and the most specific range decides an address. The audit-log alerts treat Trusted addresses as known; the BEC investigation uses Trusted and Blocked entries as confirmed-safe and confirmed-compromised IPs.", "requestBody": { "required": false, "content": { @@ -12338,10 +12483,16 @@ "type": "array", "items": { "type": "string" - } + }, + "description": "IP addresses or CIDR ranges (IPv4 or IPv6); one entry may also hold several separated by commas or spaces" + }, + "Note": { + "type": "string", + "description": "Optional note shown with the entry" }, "State": { - "type": "string" + "type": "string", + "description": "Trusted, Blocked, or NotTrusted to make the entry neutral again" } } } @@ -12715,7 +12866,13 @@ }, "MCPAllowed": { "type": "string", - "description": "When this client is MCP-enabled, configure its app registration as the MCP OAuth resource (host identifier URIs + v2 tokens) so the Claude connector flow can resolve it." + "description": "When this client is MCP-enabled it becomes one of the OAuth client apps that AI connectors sign in as. Several MCPAllowed clients may coexist (each with its own role/IP/redirects/CA); the dedicated CIPP-MCP app is the shared protected resource. Configure this client (callbacks, public client flows, resource permissions + consent) and ensure the resource app exists (Set-CIPPMCPClientApp -> New-CIPPMcpResourceApp)." + }, + "PublicRedirectUris": { + "type": "string" + }, + "RedirectUris": { + "type": "string" }, "RemoveAppReg": { "type": "boolean" @@ -12727,6 +12884,9 @@ } ], "description": "Authorize the role assignment BEFORE any side effects (app registration / secret creation). A caller may only assign a role whose effective permissions are a subset of their own, and may only modify an existing client whose current role is likewise within their grant. This blocks privilege escalation via the ApiClients table (e.g. editor -> superadmin)." + }, + "WebRedirectUris": { + "type": "string" } } } @@ -12744,11 +12904,13 @@ "AddUpdate", "Delete", "GetAzureConfiguration", + "GetMcpAuth", "List", "ListAvailable", "RepairUri", "ResetSecret", - "SaveToAzure" + "SaveToAzure", + "SetMcpRedirectUris" ] } } @@ -12760,7 +12922,7 @@ "application/json": { "schema": { "type": "object", - "description": "Derived from the Microsoft Graph entity it queries, and the fields written into the storage table it reads. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "description": "Derived from the Microsoft Graph entity it queries. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", "properties": { "addIns": { "type": "array", @@ -12778,9 +12940,6 @@ "type": "string", "x-cipp-field-source": "graph-entity" }, - "AppName": { - "x-cipp-field-source": "storage" - }, "appRoles": { "type": "array", "x-cipp-field-source": "graph-entity" @@ -12821,13 +12980,6 @@ "type": "string", "x-cipp-field-source": "graph-entity" }, - "Enabled": { - "x-cipp-field-source": "storage" - }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, "groupMembershipClaims": { "type": "string", "x-cipp-field-source": "graph-entity" @@ -12844,9 +12996,6 @@ "type": "object", "x-cipp-field-source": "graph-entity" }, - "IPRange": { - "x-cipp-field-source": "storage" - }, "isDeviceOnlyAuthSupported": { "type": "boolean", "x-cipp-field-source": "graph-entity" @@ -12871,10 +13020,6 @@ "type": "array", "x-cipp-field-source": "graph-entity" }, - "MCPAllowed": { - "type": "boolean", - "x-cipp-field-source": "storage" - }, "nativeAuthenticationApisEnabled": { "type": "object", "x-cipp-field-source": "graph-entity" @@ -12895,9 +13040,6 @@ "type": "object", "x-cipp-field-source": "graph-entity" }, - "PartitionKey": { - "x-cipp-field-source": "storage" - }, "passwordCredentials": { "type": "array", "x-cipp-field-source": "graph-entity" @@ -12918,13 +13060,6 @@ "type": "array", "x-cipp-field-source": "graph-entity" }, - "Role": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "RowKey": { - "x-cipp-field-source": "storage" - }, "samlMetadataUrl": { "type": "string", "x-cipp-field-source": "graph-entity" @@ -12949,10 +13084,6 @@ "type": "array", "x-cipp-field-source": "graph-entity" }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" - }, "tokenEncryptionKeyId": { "type": "string", "x-cipp-field-source": "graph-entity" @@ -13144,6 +13275,9 @@ "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.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -13174,11 +13308,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -13495,6 +13633,9 @@ "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateId": { "x-cipp-field-source": "backend" }, @@ -14818,6 +14959,82 @@ "x-cipp-any-tenant": true } }, + "/api/ExecBaselineAddStandard": { + "post": { + "summary": "ExecBaselineAddStandard", + "operationId": "ExecBaselineAddStandard", + "tags": [ + "Tenant > Standards" + ], + "description": "Adds one standard, with its configured settings, to a stage of an existing baseline. The baseline\nis read with Get-CIPPBaseline, the standard is appended to the stage (replacing an existing\nsingle-instance copy) and the whole baseline is saved again through New-CIPPBaseline, the same\nwrite path the baseline editor uses. Body: baselineId, stage (1-based, default 1), standard,\nvariables, remediateEnabled (default false), alertEnabled (default true).", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "alertEnabled": { + "type": "string" + }, + "baselineId": { + "type": "string" + }, + "remediateEnabled": { + "type": "boolean" + }, + "stage": { + "type": "string" + }, + "standard": { + "type": "string" + }, + "variables": { + "type": "string" + } + } + } + } + } + }, + "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": { + "Metadata": { + "x-cipp-field-source": "backend" + }, + "Results": { + "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": "Tenant.Baselines.ReadWrite" + } + }, "/api/ExecBaselineMigrate": { "post": { "summary": "ExecBaselineMigrate", @@ -15101,51 +15318,14 @@ "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, - "/api/ExecBECCheck": { + "/api/ExecBECBulkCheck": { "get": { - "summary": "ExecBECCheck", - "operationId": "ExecBECCheck", + "summary": "Queues Business Email Compromise investigations for many users at once.", + "operationId": "ExecBECBulkCheck", "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.", - "parameters": [ - { - "name": "GUID", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "overwrite", - "in": "query", - "required": false, - "schema": { - "type": "boolean" - } - }, - { - "$ref": "#/components/parameters/tenantFilter" - }, - { - "name": "userid", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "userName", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ], + "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[]. 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", @@ -15153,42 +15333,7 @@ "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.", - "properties": { - "Batch": { - "x-cipp-field-source": "backend" - }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "OrchestratorName": { - "x-cipp-field-source": "backend" - }, - "PartitionKey": { - "x-cipp-field-source": "storage" - }, - "Results": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "RowKey": { - "x-cipp-field-source": "storage" - }, - "SkipLog": { - "x-cipp-field-source": "backend" - }, - "Status": { - "x-cipp-field-source": "storage" - }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "UserId": { - "x-cipp-field-source": "storage" - } - } + "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." } } } @@ -15198,6 +15343,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" } }, "security": [ @@ -15208,13 +15356,14 @@ "x-cipp-role": "Identity.User.Read" } }, - "/api/ExecBECRemediate": { + "/api/ExecBECCheck": { "post": { - "summary": "ExecBECRemediate", - "operationId": "ExecBECRemediate", + "summary": "Reads, polls or starts a Business Email Compromise investigation.", + "operationId": "ExecBECCheck", "tags": [ "Identity > Administration > Users" ], + "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 }. Every run is the full investigation and is kept in the BecReports table; metadata only, never message content.", "requestBody": { "required": true, "content": { @@ -15226,10 +15375,12 @@ "type": "string" }, "userid": { - "type": "string" + "type": "string", + "description": "Object id of the user to investigate" }, - "username": { - "type": "string" + "userName": { + "type": "string", + "description": "The user's UPN (stored on the run and used by the collectors)" } }, "required": [ @@ -15239,6 +15390,47 @@ } } }, + "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" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "userid", + "in": "query", + "description": "Object id of the user to investigate", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "userName", + "in": "query", + "description": "The user's UPN (stored on the run and used by the collectors)", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success", @@ -15248,7 +15440,22 @@ "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": { - "Results": { + "CaseId": { + "x-cipp-field-source": "backend" + }, + "Containment": { + "x-cipp-field-source": "backend" + }, + "ExtractedAt": { + "x-cipp-field-source": "backend" + }, + "RequestedAt": { + "x-cipp-field-source": "backend" + }, + "RequestedBy": { + "x-cipp-field-source": "backend" + }, + "Status": { "x-cipp-field-source": "backend" } } @@ -15271,70 +15478,309 @@ "bearerAuth": [] } ], - "x-cipp-role": "Identity.User.ReadWrite" + "x-cipp-role": "Identity.User.Read" } }, - "/api/ExecBitlockerSearch": { + "/api/ExecBECEvidenceExport": { "post": { - "summary": "ExecBitlockerSearch", - "operationId": "ExecBitlockerSearch", + "summary": "Builds the evidence package for a Business Email Compromise run and returns it.", + "operationId": "ExecBECEvidenceExport", "tags": [ - "Endpoint > MEM" + "Identity > Administration > Users" ], - "description": "Finds a BitLocker recovery key across every tenant the caller can access, by keyId or deviceId. Intended for the case where a user has a recovery key ID from the BitLocker prompt but the tenant is unknown.", + "description": "Collates the run's stored results, one CSV per finding set, the score, the containment history, every logbook entry stamped with the case id and the full and C-suite-summary report PDFs (rendered server-side) into a ZIP. Nothing is stored: the ZIP is returned base64-encoded (ZipBase64) for the browser to save. Metadata only - nothing in the package is message content.", "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "deviceId": { - "type": "string" - }, - "keyId": { - "type": "string", - "description": "Get search parameters from query string or POST body" - }, - "limit": { + "caseId": { "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" + }, + "CaseId": { + "x-cipp-field-source": "backend" + }, + "FileCount": { + "x-cipp-field-source": "backend" + }, + "ZipBase64": { + "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" } }, - "parameters": [ + "security": [ { - "name": "deviceId", - "in": "query", - "required": false, - "schema": { - "type": "string" + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.Read" + } + }, + "/api/ExecBECIPReview": { + "post": { + "summary": "Re-judges the IP addresses of a BEC case with the investigator's overrides and chosen accounts.", + "operationId": "ExecBECIPReview", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Validates the request and queues a background re-run of the case's address-dependent sections: the investigator's overrides (an address or CIDR range marked Safe or Compromised, with an optional note) become the case's complete override set, the sign-ins of the chosen accounts are correlated with the case's addresses, the address lists are re-read, and mailbox activity, IP verdicts, attacker activity, delegated mailboxes and the score are recomputed for the original window. Returns a DeploymentId whose per-step progress ListOffboardingProgress returns. To make a verdict permanent for the tenant, add the address to CIPP's IP allow/block list (ExecAddTrustedIP).", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "CaseId": { + "type": "string", + "description": "The BEC case (run) to review" + }, + "CorrelateUsers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + }, + "description": "Object ids of other accounts whose sign-ins to correlate with the case's addresses" + }, + "Overrides": { + "type": "array", + "items": { + "type": "object", + "properties": { + "IP": { + "type": "string" + }, + "Note": { + "type": "string" + }, + "Verdict": { + "$ref": "#/components/schemas/LabelValue" + } + } + }, + "description": "The complete set of overrides for the case: an address or CIDR range, Safe or Compromised, and a note. Auto drops the override." + }, + "tenantFilter": { + "$ref": "#/components/schemas/LabelValue" + } + }, + "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": { + "resultText": { + "x-cipp-field-source": "backend" + }, + "state": { + "x-cipp-field-source": "backend" + } + } + } + } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ { - "name": "keyId", - "in": "query", - "description": "Get search parameters from query string or POST body", - "required": false, - "schema": { - "type": "string" + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.ReadWrite" + } + }, + "/api/ExecBECRemediate": { + "post": { + "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 default set runs (the DefaultSelected actions, configurable instance-wide from CIPP settings). Actions marked Critical require Confirmation to equal the user's UPN. With Async the request is validated, queued as a scheduled task that runs straight away, and answered with a DeploymentId whose per-action progress ListOffboardingProgress returns; without it the actions run inline and their results are returned. 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": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Actions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + }, + "description": "Action ids from ListBECRemediationActions; empty runs the default set" + }, + "Async": { + "type": "boolean", + "description": "run as a background scheduled task and report progress under the returned DeploymentId" + }, + "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" + }, + "userid": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "tenantFilter", + "userid", + "username" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. The fields taken from Graph are the ones this endpoint selects, so they are what the response actually carries.", + "properties": { + "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": [ { - "name": "limit", - "in": "query", - "required": false, - "schema": { - "type": "string" + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.ReadWrite" + } + }, + "/api/ExecBECRemediationDefaults": { + "post": { + "summary": "Gets or sets which BEC containment actions are selected by default.", + "operationId": "ExecBECRemediationDefaults", + "tags": [ + "CIPP > Settings" + ], + "description": "GET with List=true returns the containment catalog (Id, Label, Description, Impact, DefaultSelected) with the instance-wide defaults applied. POST with DefaultActions saves the action ids that start selected in the BEC containment drawer and run when ExecBECRemediate is called without Actions.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "DefaultActions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + }, + "description": "Containment action ids (ListBECRemediationActions) that start selected" + } + } + } } - }, + } + }, + "parameters": [ { - "name": "tenantFilter", + "name": "List", "in": "query", "required": false, "schema": { @@ -15371,17 +15817,81 @@ "bearerAuth": [] } ], - "x-cipp-role": "Endpoint.Device.Read", + "x-cipp-role": "CIPP.AppSettings.ReadWrite", "x-cipp-any-tenant": true } }, - "/api/ExecBPA": { + "/api/ExecBECReport": { "post": { - "summary": "ExecBPA", - "operationId": "ExecBPA", + "summary": "Manages a stored Business Email Compromise run.", + "operationId": "ExecBECReport", "tags": [ - "Tenant > Standards" + "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" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } ], + "x-cipp-role": "Identity.User.ReadWrite" + } + }, + "/api/ExecBitlockerSearch": { + "post": { + "summary": "ExecBitlockerSearch", + "operationId": "ExecBitlockerSearch", + "tags": [ + "Endpoint > MEM" + ], + "description": "Finds a BitLocker recovery key across every tenant the caller can access, by keyId or deviceId. Intended for the case where a user has a recovery key ID from the BitLocker prompt but the tenant is unknown.", "requestBody": { "required": false, "content": { @@ -15389,8 +15899,18 @@ "schema": { "type": "object", "properties": { - "tenantfilter": { - "$ref": "#/components/schemas/LabelValue" + "deviceId": { + "type": "string" + }, + "keyId": { + "type": "string", + "description": "Get search parameters from query string or POST body" + }, + "limit": { + "type": "string" + }, + "tenantFilter": { + "type": "string" } } } @@ -15398,12 +15918,37 @@ } }, "parameters": [ + { + "name": "deviceId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "keyId", + "in": "query", + "description": "Get search parameters from query string or POST body", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "tenantFilter", "in": "query", "required": false, "schema": { - "$ref": "#/components/schemas/LabelValue" + "type": "string" } } ], @@ -15413,22 +15958,22 @@ "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": { - "Results": { - "x-cipp-field-source": "backend" - } - } + "$ref": "#/components/schemas/StandardResults" } } } }, + "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": [ @@ -15436,7 +15981,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.BestPracticeAnalyser.ReadWrite", + "x-cipp-role": "Endpoint.Device.Read", "x-cipp-any-tenant": true } }, @@ -15482,7 +16027,8 @@ "type": "string" }, "kind": { - "type": "string" + "type": "string", + "description": "A name for a gallery image, so it can be picked by name where the image is offered elsewhere: the report builder lists uploaded covers as Infographic page backgrounds." }, "logoImageId": { "type": "string" @@ -15509,6 +16055,10 @@ "showPageNumbers": { "type": "boolean" }, + "tenantLabel": { + "type": "string", + "description": "Which of the tenant's names the preset's reports print; see the Set action." + }, "watermarkEnabled": { "type": "boolean" }, @@ -15719,7 +16269,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read" + "x-cipp-role": "CIPP.BreachLookup.Read" } }, "/api/ExecBulkLicense": { @@ -15895,7 +16445,7 @@ "tags": [ "Tenant > Conditional" ], - "description": "Runs a Conditional Access \"what if\" evaluation, reporting which policies would apply to a sign-in with the given user, application, device platform, location, client app type and risk levels. Evaluation only - no sign-in occurs and no policy is changed.", + "description": "Runs a Conditional Access \"what if\" evaluation, reporting which policies would apply to a sign-in with the given user, application, device platform, device compliance, location, client app type, authentication flow and risk levels. Evaluation only - no sign-in occurs and no policy is changed.", "requestBody": { "required": true, "content": { @@ -15912,10 +16462,19 @@ "Country": { "$ref": "#/components/schemas/LabelValue" }, + "DeviceCompliant": { + "$ref": "#/components/schemas/LabelValue" + }, "DevicePlatform": { "$ref": "#/components/schemas/LabelValue" }, "IncludeApplications": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + } + }, + "InsiderRiskLevel": { "$ref": "#/components/schemas/LabelValue" }, "IpAddress": { @@ -15967,7 +16526,10 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.ConditionalAccess.Read" + "x-cipp-role": "Tenant.ConditionalAccess.Read", + "x-cipp-reads-via": [ + "New-CIPPCAWhatIfRequest" + ] } }, "/api/ExecCAExclusion": { @@ -16678,6 +17240,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -16702,11 +17267,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -16767,19 +17336,71 @@ } } }, - "parameters": [ - { - "name": "ID", - "in": "query", - "required": false, - "schema": { - "type": "string" + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } } }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ { - "$ref": "#/components/parameters/tenantFilter" + "bearerAuth": [] } ], + "x-cipp-role": "Identity.User.ReadWrite" + } + }, + "/api/ExecClrOnPremAttributes": { + "post": { + "summary": "ExecClrOnPremAttributes", + "operationId": "ExecClrOnPremAttributes", + "tags": [ + "Identity > Administration > Users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "Attributes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/LabelValue" + }, + "description": "On-premises attributes to clear (onPremisesImmutableId, onPremisesDistinguishedName, ...), as plain names or {label,value} objects. Omit to clear every clearable attribute." + }, + "ID": { + "type": "string", + "description": "Object ID of the cloud-only user whose on-premises attributes should be cleared" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, "responses": { "200": { "description": "Success", @@ -16973,19 +17594,16 @@ "type": "boolean" }, "FullName": { - "type": "string", - "description": "Pretty-printed, not compressed: repo files are hand-edited on GitHub." + "type": "string" }, "GUID": { - "type": "string", - "description": "A baseline is not a templates-table row: Export-CIPPBaselineTemplate assembles the portable set - the BaselineTemplate file plus one standard template file per referenced CA/Intune template (packages expanded to their current members). Related templates are separate files, exactly the shape UploadTemplate writes, so they import through the untouched path." + "type": "string" }, "Id": { "type": "string" }, "Message": { - "type": "string", - "description": "Pretty-printed, not compressed: repo files are hand-edited on GitHub." + "type": "string" }, "Path": { "type": "string" @@ -17022,7 +17640,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.ReadWrite", + "x-cipp-role": "CIPP.TemplateLibrary.ReadWrite", "x-cipp-any-tenant": true } }, @@ -17137,6 +17755,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -17161,11 +17782,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -17414,7 +18039,7 @@ "summary": "ExecCopilotSettings", "operationId": "ExecCopilotSettings", "tags": [ - "Tenant > Standards" + "Tenant > AI" ], "description": "Sets a single Microsoft 365 Copilot policy setting to Enabled (1), Disabled (0) or Not configured (cleared).", "requestBody": { @@ -17487,7 +18112,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.AI.ReadWrite" } }, "/api/ExecCopyForSent": { @@ -17970,6 +18595,10 @@ "schema": { "type": "object", "properties": { + "generatePwPushLink": { + "type": "boolean", + "description": "Opt-in: most TAPs are used directly by the admin, so the pass is returned in plain text unless the caller asks for a PwPush link. Same string-to-bool normalisation as above." + }, "ID": { "type": "string" }, @@ -17996,6 +18625,15 @@ } }, "parameters": [ + { + "name": "generatePwPushLink", + "in": "query", + "description": "Opt-in: most TAPs are used directly by the admin, so the pass is returned in plain text unless the caller asks for a PwPush link. Same string-to-bool normalisation as above.", + "required": false, + "schema": { + "type": "boolean" + } + }, { "name": "ID", "in": "query", @@ -20393,6 +21031,9 @@ "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.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -20420,11 +21061,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -21051,9 +21696,6 @@ "ExcludeUser": { "x-cipp-field-source": "storage,frontend" }, - "GraphErrorCount": { - "x-cipp-field-source": "storage" - }, "hasAutoExtend": { "x-cipp-field-source": "storage" }, @@ -22527,6 +23169,162 @@ "$ref": "#/components/parameters/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." + } + } + } + }, + "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" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.ReportBuilder.ReadWrite" + } + }, + "/api/ExecGeoIPLookup": { + "post": { + "summary": "ExecGeoIPLookup", + "operationId": "ExecGeoIPLookup", + "tags": [ + "CIPP > Core" + ], + "description": "Resolves a single IP address to its geographic location using CIPP's bundled GeoIP database.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "IP": { + "type": "string" + } + } + } + } + } + }, + "parameters": [ + { + "name": "IP", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "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" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.Core.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ExecGetBaselineWhatIfReportPdf": { + "post": { + "summary": "ExecGetBaselineWhatIfReportPdf", + "operationId": "ExecGetBaselineWhatIfReportPdf", + "tags": [ + "Tenant > Standards" + ], + "description": "Server-renders the Security Baseline report for a tenant as application/pdf bytes: where the\nassigned baselines stand today, what is already in place, the Conditional Access and Intune\npolicies and the settings the baseline will change (what each does, its value today, the value\nit will be set to and why), the rollout waves still to come and the agreed exceptions - plus,\noptionally, everything one or more simulated (not yet assigned) baselines would add. Reads the\nsame alignment, baseline and catalog data the Baselines page shows, composes it through the\nshared CIPPSharp component kit (Build-CippBaselineWhatIfReportTree) and returns the finished\nPDF. Nothing is changed by producing it.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "brandingPresetId": { + "type": "string", + "description": "Optional. The branding preset to render with; an unknown id falls back to the default branding." + }, + "sectionConfig": { + "type": "object", + "properties": { + "alreadyAligned": { + "type": "string", + "description": "Optional. Show the \"What Is Already In Place\" page (deployed policies and enforced settings, with their values). A boolean; defaults to true." + }, + "rolloutStages": { + "type": "string", + "description": "Optional. Show the \"How The Rollout Works\" section (the remaining waves and when they arrive). A boolean; defaults to true." + } + }, + "description": "Optional. Show the \"What Is Already In Place\" page (deployed policies and enforced settings, with their values). A boolean; defaults to true." + }, + "simulatedTemplateIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "tenantFilter": { + "type": "string", + "description": "Required. The tenant (default domain) to report on." + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "name": "brandingPresetId", + "in": "query", + "description": "Optional. The branding preset to render with; an unknown id falls back to the default branding.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], "responses": { "200": { "description": "Success", @@ -22536,42 +23334,296 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { - "Blocks": { - "type": "string", + "ContentHash": { "x-cipp-field-source": "storage" }, "ETag": { "type": "string", "x-cipp-field-source": "storage" }, - "GeneratedAt": { - "type": "string", + "GUID": { + "x-cipp-field-source": "storage" + }, + "JSON": { + "x-cipp-field-source": "storage" + }, + "Package": { "x-cipp-field-source": "storage" }, "PartitionKey": { "x-cipp-field-source": "storage" }, + "Permissions": { + "type": "string", + "x-cipp-field-source": "storage" + }, "RowKey": { + "x-cipp-field-source": "storage" + }, + "SHA": { "type": "string", "x-cipp-field-source": "storage" }, - "Settings": { + "Source": { "x-cipp-field-source": "storage" }, - "Status": { + "SourcePath": { "x-cipp-field-source": "storage" }, "TemplateName": { + "x-cipp-field-source": "storage" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "UpdatedBy": { + "x-cipp-field-source": "storage" + } + } + } + } + } + }, + "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": "Tenant.Baselines.Read" + } + }, + "/api/ExecGetBecReportPdf": { + "post": { + "summary": "ExecGetBecReportPdf", + "operationId": "ExecGetBecReportPdf", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Server-renders a stored Business Email Compromise (BEC) run as application/pdf bytes. Reads the\nrun through Get-CIPPBecReport (the BecReports metadata row plus its BecResults payload) and\ncomposes it through the shared CIPPSharp component kit (Build-CippBecReportTree) - the server-side\nreplacement for the client react-pdf BECRemediationReportButton. A run is named by its caseId;\npass userId instead to render the user's newest completed run. The run must have completed - the\nreport reads its stored result, it does not trigger a new investigation.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "caseId": { + "type": "string", + "description": "The stored run to render. A caseId names it directly; a userId picks the user's newest completed run." + }, + "tenantFilter": { + "type": "string" + }, + "userDisplayName": { + "type": "string", + "description": "The investigated user labels the cover and footer; the run stored who it was for." + }, + "userId": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "variant": { + "type": "string", + "description": "'full' (default) = every page; 'summary' = the executive lead only, for a C-suite reader" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "name": "caseId", + "in": "query", + "description": "The stored run to render. A caseId names it directly; a userId picks the user's newest completed run.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "userDisplayName", + "in": "query", + "description": "The investigated user labels the cover and footer; the run stored who it was for.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "userId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "userName", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "variant", + "in": "query", + "description": "'full' (default) = every page; 'summary' = the executive lead only, for a C-suite reader", + "required": false, + "schema": { + "type": "string" + } + } + ], + "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." + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.User.Read" + } + }, + "/api/ExecGetExecutiveReportPdf": { + "post": { + "summary": "ExecGetExecutiveReportPdf", + "operationId": "ExecGetExecutiveReportPdf", + "tags": [ + "Tenant > Standards" + ], + "description": "Server-renders the Executive Summary report as application/pdf bytes. Every figure is read from the\nnightly Reporting DB cache via New-CIPPDbRequest (Users/Guests/Roles for the environment overview,\nLicenseOverview, ManagedDevices, ConditionalAccessPolicies and SecureScore) plus the standards\ncomparison from the CippStandardsReports table resolved against the standards catalog - the same\ncached data the rest of CIPP reports from, with no live Graph or cross-endpoint HTTP calls. The\nshaped data is composed through the shared CIPPSharp component kit (Build-CippExecutiveReportTree),\nthe server-side replacement for the client react-pdf ExecutiveReportButton. Each source is gathered\ndefensively: a source with no cached data simply drops its section.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "brandingPresetId": { + "type": "string", + "description": "The branding preset a caller picked for this render, else the global branding settings." + }, + "sectionConfig": { + "type": "string" + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "name": "brandingPresetId", + "in": "query", + "description": "The branding preset a caller picked for this render, else the global branding settings.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "CurrentValue": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "ETag": { "type": "string", "x-cipp-field-source": "storage" }, - "TenantFilter": { + "ExpectedValue": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "LicenseAvailable": { + "type": "boolean", + "x-cipp-field-source": "storage" + }, + "PartitionKey": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "RowKey": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "TemplateId": { "type": "string", "x-cipp-field-source": "storage" }, "Timestamp": { "type": "string", "x-cipp-field-source": "storage" + }, + "Value": { + "x-cipp-field-source": "storage" } } } @@ -22586,6 +23638,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" } }, "security": [ @@ -22593,40 +23648,158 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.ReadWrite" + "x-cipp-role": "Tenant.Standards.Read" } }, - "/api/ExecGeoIPLookup": { + "/api/ExecGetLicenseReportPdf": { "post": { - "summary": "ExecGeoIPLookup", - "operationId": "ExecGeoIPLookup", + "summary": "ExecGetLicenseReportPdf", + "operationId": "ExecGetLicenseReportPdf", "tags": [ - "CIPP > Core" + "Tenant > Reports" ], - "description": "Resolves a single IP address to its geographic location using CIPP's bundled GeoIP database.", + "description": "Server-renders the License Optimisation report as application/pdf bytes. Runs the same license\nrecommendation analysis the License Optimization page reads through ListLicenseRecommendations\n(Get-CIPPLicenseRecommendation, with the page's currency, thresholds and recommendation switches)\nand composes it through the shared CIPPSharp kit (Build-CippLicenseReportTree) - the server-side\nreplacement for the client react-pdf LicenseReportButton.", "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", "properties": { - "IP": { + "brandingPresetId": { + "type": "string", + "description": "The branding preset to render against (the Licensing Report default from the branding settings), else the global branding." + }, + "currency": { + "type": "string", + "description": "Currency the money figures are resolved in: a three-letter ISO code such as USD or EUR. Defaults to USD." + }, + "inactiveDays": { + "type": "string", + "description": "Days without a sign-in after which a licensed person counts as inactive (1-365). Defaults to 90." + }, + "protectSecurityFeatures": { "type": "string" + }, + "recommendDowngrades": { + "type": "string" + }, + "recommendTerms": { + "type": "string" + }, + "recommendUpgrades": { + "type": "string" + }, + "sections": { + "type": "object", + "properties": { + "downgrades": { + "type": "boolean" + }, + "method": { + "type": "boolean" + }, + "reclaim": { + "type": "boolean" + }, + "spend": { + "type": "boolean" + }, + "terms": { + "type": "boolean" + }, + "upgrades": { + "type": "boolean" + } + } + }, + "tenantFilter": { + "type": "string", + "description": "The tenant to report on. A single tenant; AllTenants is not supported." + }, + "tenureMonths": { + "type": "string", + "description": "Months a seat must have been held by the same person to count as stable for a yearly commitment (1-36). Defaults to 6." } - } + }, + "required": [ + "tenantFilter" + ] } } } }, "parameters": [ { - "name": "IP", + "name": "brandingPresetId", + "in": "query", + "description": "The branding preset to render against (the Licensing Report default from the branding settings), else the global branding.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "currency", + "in": "query", + "description": "Currency the money figures are resolved in: a three-letter ISO code such as USD or EUR. Defaults to USD.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "inactiveDays", + "in": "query", + "description": "Days without a sign-in after which a licensed person counts as inactive (1-365). Defaults to 90.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "protectSecurityFeatures", "in": "query", "required": false, "schema": { "type": "string" } + }, + { + "name": "recommendDowngrades", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "recommendTerms", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "recommendUpgrades", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "tenureMonths", + "in": "query", + "description": "Months a seat must have been held by the same person to count as stable for a yearly commitment (1-36). Defaults to 6.", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -22641,11 +23814,17 @@ } } }, + "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": [ @@ -22653,8 +23832,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", - "x-cipp-any-tenant": true + "x-cipp-role": "Tenant.Directory.Read" } }, "/api/ExecGetLocalAdminPassword": { @@ -22718,6 +23896,143 @@ "x-cipp-role": "Endpoint.Device.Read" } }, + "/api/ExecGetMailFlowReportPdf": { + "post": { + "summary": "ExecGetMailFlowReportPdf", + "operationId": "ExecGetMailFlowReportPdf", + "tags": [ + "Email-Exchange > Reports" + ], + "description": "Server-renders the Exchange mail flow report as application/pdf bytes. Reads the same three Exchange\nreports the Mail Flow page uses (Get-MailFlowStatusReport plus the TopMailSender and\nTopSpamRecipient traffic summaries), aggregates the daily disposition rows the way the page does,\nand composes them through the shared CIPPSharp kit (Build-CippMailFlowReportTree) - the server-side\nreplacement for the client react-pdf MailFlowReportButton.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "name": "days", + "in": "query", + "description": "Reporting window in days (1-90).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/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." + } + } + } + }, + "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": "Exchange.Mailbox.Read" + } + }, + "/api/ExecGetPermissionsReportPdf": { + "post": { + "summary": "ExecGetPermissionsReportPdf", + "operationId": "ExecGetPermissionsReportPdf", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Server-renders the SharePoint Permissions report as application/pdf bytes. Gathers the same shaped\ndata the Permissions page uses (ListSharePointPermissions, from the CIPP reporting cache), composes\nit through the shared CIPPSharp component kit (Build-CippPermissionsReportTree) and returns the\nfinished PDF - the server-side replacement for the client react-pdf PermissionsReportButton.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/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." + } + } + } + }, + "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": "Sharepoint.Site.Read" + } + }, "/api/ExecGetRecoveryKey": { "post": { "summary": "ExecGetRecoveryKey", @@ -22798,6 +24113,210 @@ "x-cipp-role": "Endpoint.Device.Read" } }, + "/api/ExecGetReportBuilderPdf": { + "get": { + "summary": "ExecGetReportBuilderPdf", + "operationId": "ExecGetReportBuilderPdf", + "tags": [ + "Tools > Report-Builder" + ], + "description": "Returns the server-rendered PDF for a generated Report Builder report as application/pdf bytes.\nBacks both the in-app preview (shown in an iframe) and the download button on the view page.\nA report generated before server-side rendering (or whose render failed) is rendered from its\nstored blocks on first request and cached. 404 when the report does not exist, and also when\nthe report belongs to a tenant the caller cannot access.", + "parameters": [ + { + "name": "id", + "in": "query", + "description": "The generated report's GUID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "ReportGUID", + "in": "query", + "description": "The generated report's GUID.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "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": { + "PartitionKey": { + "x-cipp-field-source": "backend" + } + } + } + } + } + }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + }, + "404": { + "description": "Not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.ReportBuilder.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ExecGetShadowAIReportPdf": { + "post": { + "summary": "ExecGetShadowAIReportPdf", + "operationId": "ExecGetShadowAIReportPdf", + "tags": [ + "Tenant > AI" + ], + "description": "Server-renders the Shadow AI report as application/pdf bytes. Gathers the same shaped data the\nShadow AI page uses (ListShadowAI) and composes it through the shared CIPPSharp component kit\n(Build-CippShadowAIReportTree) - the server-side replacement for the client react-pdf\nShadowAIReportButton.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "sectionConfig": { + "type": "array", + "items": { + "type": "string" + } + }, + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/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." + } + } + } + }, + "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": "Tenant.AI.Read" + } + }, + "/api/ExecGetSharingReportPdf": { + "post": { + "summary": "ExecGetSharingReportPdf", + "operationId": "ExecGetSharingReportPdf", + "tags": [ + "Teams-Sharepoint" + ], + "description": "Server-renders the SharePoint & OneDrive Sharing report as application/pdf bytes. Gathers the same\nshaped data the Sharing page uses (ListSharePointSharing, from the CIPP reporting cache), composes\nit through the shared CIPPSharp component kit (Build-CippSharingReportTree) and returns the finished\nPDF - the server-side replacement for the client react-pdf SharingReportButton.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "tenantFilter": { + "type": "string" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "parameters": [ + { + "$ref": "#/components/parameters/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." + } + } + } + }, + "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": "Sharepoint.Site.Read" + } + }, "/api/ExecGitHubAction": { "post": { "summary": "Invoke GitHub Action", @@ -23904,7 +25423,7 @@ "tags": [ "Email-Exchange > Tools" ], - "description": "Enables or disables Microsoft Purview Message Encryption for a tenant by setting AzureRMSLicensingEnabled and the Outlook Encrypt button by setting SimplifiedClientAccessEnabled, or runs Test-IRMConfiguration to verify that encryption and decryption work end to end.", + "description": "Updates the Microsoft Purview Message Encryption configuration for a tenant (AzureRMSLicensingEnabled, SimplifiedClientAccessEnabled, EnablePdfEncryption, DecryptAttachmentForEncryptOnly, SimplifiedClientAccessDoNotForwardDisabled, SimplifiedClientAccessEncryptOnlyDisabled, TransportDecryptionSetting; only the settings present in the body are changed), or runs Test-IRMConfiguration to verify that encryption and decryption work end to end.", "requestBody": { "required": true, "content": { @@ -23919,21 +25438,17 @@ "Test" ] }, - "AzureRMSLicensingEnabled": { - "type": "string" - }, "Recipient": { "type": "string" }, "Sender": { "type": "string" }, - "SimplifiedClientAccessEnabled": { - "type": "string", - "description": "Only touch the Encrypt button setting when the caller sent it, so an API client that posts just AzureRMSLicensingEnabled does not silently disable it." - }, "tenantFilter": { "type": "string" + }, + "TransportDecryptionSetting": { + "type": "string" } }, "required": [ @@ -28282,6 +29797,9 @@ }, "standardsExcludeAllTenants": { "type": "boolean" + }, + "tenantGroups": { + "type": "string" } } } @@ -28333,6 +29851,9 @@ "Status": { "x-cipp-field-source": "storage,backend" }, + "TenantGroups": { + "x-cipp-field-source": "storage,backend" + }, "Timestamp": { "type": "string", "x-cipp-field-source": "storage" @@ -28382,7 +29903,10 @@ "type": "string" }, "UserPrincipalName": { - "type": "string" + "type": "array", + "items": { + "type": "string" + } } }, "required": [ @@ -28983,21 +30507,207 @@ "x-cipp-role": "Identity.User.ReadWrite" } }, - "/api/ExecPermissionRepair": { + "/api/ExecPermissionRepair": { + "post": { + "summary": "This endpoint will update the CIPP-SAM app permissions.", + "operationId": "ExecPermissionRepair", + "tags": [ + "CIPP > Settings" + ], + "description": "Merges new permissions from the SAM manifest into the AppPermissions entry for CIPP-SAM.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "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" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.AppSettings.ReadWrite" + } + }, + "/api/ExecPerUserMFA": { + "post": { + "summary": "ExecPerUserMFA", + "operationId": "ExecPerUserMFA", + "tags": [ + "Identity > Administration > Users" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "State": { + "$ref": "#/components/schemas/LabelValue" + }, + "tenantFilter": { + "type": "string" + }, + "userId": { + "type": "string", + "description": "Guest user handling" + }, + "userPrincipalName": { + "type": "string", + "description": "Guest user handling" + } + }, + "required": [ + "tenantFilter" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StandardResults" + } + } + } + }, + "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/ExecPIMRoleAssignment": { "post": { - "summary": "This endpoint will update the CIPP-SAM app permissions.", - "operationId": "ExecPermissionRepair", + "summary": "Change a directory role assignment through PIM in the secure direction only.", + "operationId": "ExecPIMRoleAssignment", "tags": [ - "CIPP > Settings" + "Identity > Administration > Roles" ], - "description": "Merges new permissions from the SAM manifest into the AppPermissions entry for CIPP-SAM.", + "description": "Converts a permanent assignment to eligible, grants a time-bound active assignment, extends or renews a time-bound assignment or eligibility, or removes an assignment. Every request must carry an expiration (a duration or an end date); permanent / no-expiration assignments are refused, as are changes to group-inherited rows, the CIPP-SAM application and the last active Global Administrator. Requires Entra ID P2.", "requestBody": { - "required": false, + "required": true, "content": { "application/json": { "schema": { "type": "object", - "properties": {} + "properties": { + "Action": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "ConvertToEligible | GrantActive | Extend | Renew | Remove" + }, + "AssignmentType": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "The row's current assignment type: Permanent | Active | ActivatedFromEligible | Eligible" + }, + "DirectoryScopeId": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "'/' for the whole directory or '/administrativeUnits/{id}'." + }, + "Duration": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "ISO 8601 lifetime such as PT4H or P1Y. Use either Duration or EndDateTime, not both." + }, + "EndDateTime": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "Absolute end (unix seconds or ISO 8601). Use either Duration or EndDateTime, not both." + }, + "Justification": { + "type": "string", + "description": "Reason recorded on the PIM request and in the CIPP logbook." + }, + "PrincipalId": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "Object id of the user, group or service principal." + }, + "RoleDefinitionId": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "Role template id (roleDefinitionId as PIM reports it)." + }, + "tenantFilter": { + "$ref": "#/components/schemas/LabelValue" + }, + "TimeZone": { + "allOf": [ + { + "$ref": "#/components/schemas/LabelValue" + } + ], + "description": "IANA time zone of the browser (e.g. Australia/Perth); only used to word the end time in the result." + } + }, + "required": [ + "tenantFilter" + ] } } } @@ -29008,12 +30718,14 @@ "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." + "$ref": "#/components/schemas/StandardResults" } } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -29026,41 +30738,43 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.AppSettings.ReadWrite" + "x-cipp-role": "Identity.Role.ReadWrite" } }, - "/api/ExecPerUserMFA": { + "/api/ExecPreviewBrandingReportPdf": { "post": { - "summary": "ExecPerUserMFA", - "operationId": "ExecPerUserMFA", + "summary": "ExecPreviewBrandingReportPdf", + "operationId": "ExecPreviewBrandingReportPdf", "tags": [ - "Identity > Administration > Users" + "CIPP > Settings" ], + "description": "Renders a sample report against a branding configuration - normally the unsaved state of the\nbranding editor - and returns it as application/pdf bytes, so a colour, logo, cover, footer or\nwatermark can be judged on every page of a real report before it is saved. Each report type has\na fixed sample of the data its builder needs (Config/ReportSamples/.json); nothing is read\nfrom a tenant.", "requestBody": { - "required": true, + "required": false, "content": { "application/json": { "schema": { "type": "object", "properties": { - "State": { - "$ref": "#/components/schemas/LabelValue" - }, - "tenantFilter": { - "type": "string" + "branding": { + "type": "object", + "properties": { + "tenantLabel": { + "type": "string", + "description": "The sample tenant, named the way the branding under edit would name a real one." + } + }, + "description": "The branding to render against, in the shape Get-CIPPBrandingSettings returns: colours (flat or under roleColours), a data-URL logo and cover or a coverStock path, footer, watermark and tenantLabel. Omitted -> the saved branding settings." }, - "userId": { + "reportType": { "type": "string", - "description": "Guest user handling" + "description": "Which report to preview: executive, reportBuilder, shadowAI, bec, becSummary, sharing, permissions, mailFlow, licensing or baseline." }, - "userPrincipalName": { + "tenantFilter": { "type": "string", - "description": "Guest user handling" + "description": "Optional: the tenant whose %variables% (%cippurl%, custom variables) resolve in the footer and cover." } - }, - "required": [ - "tenantFilter" - ] + } } } } @@ -29071,11 +30785,15 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StandardResults" + "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." } } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -29091,17 +30809,18 @@ "bearerAuth": [] } ], - "x-cipp-role": "Identity.User.ReadWrite" + "x-cipp-role": "CIPP.AppSettings.Read", + "x-cipp-any-tenant": true } }, - "/api/ExecPIMRoleAssignment": { + "/api/ExecPreviewReportBuilderPdf": { "post": { - "summary": "Change a directory role assignment through PIM in the secure direction only.", - "operationId": "ExecPIMRoleAssignment", + "summary": "ExecPreviewReportBuilderPdf", + "operationId": "ExecPreviewReportBuilderPdf", "tags": [ - "Identity > Administration > Roles" + "Tools > Report-Builder" ], - "description": "Converts a permanent assignment to eligible, grants a time-bound active assignment, extends or renews a time-bound assignment or eligibility, or removes an assignment. Every request must carry an expiration (a duration or an end date); permanent / no-expiration assignments are refused, as are changes to group-inherited rows, the CIPP-SAM application and the last active Global Administrator. Requires Entra ID P2.", + "description": "Renders the current (unsaved) Report Builder state to a PDF and returns it as application/pdf\nbytes without persisting a generated-report row. Powers the builder's live preview and download.", "requestBody": { "required": true, "content": { @@ -29109,104 +30828,54 @@ "schema": { "type": "object", "properties": { - "Action": { - "allOf": [ - { - "$ref": "#/components/schemas/LabelValue" - } - ], - "description": "ConvertToEligible | GrantActive | Extend | Renew | Remove" - }, - "AssignmentType": { - "allOf": [ - { - "$ref": "#/components/schemas/LabelValue" - } - ], - "description": "The row's current assignment type: Permanent | Active | ActivatedFromEligible | Eligible" - }, - "DirectoryScopeId": { - "allOf": [ - { - "$ref": "#/components/schemas/LabelValue" - } - ], - "description": "'/' for the whole directory or '/administrativeUnits/{id}'." - }, - "Duration": { - "allOf": [ - { - "$ref": "#/components/schemas/LabelValue" - } - ], - "description": "ISO 8601 lifetime such as PT4H or P1Y. Use either Duration or EndDateTime, not both." - }, - "EndDateTime": { - "allOf": [ - { - "$ref": "#/components/schemas/LabelValue" - } - ], - "description": "Absolute end (unix seconds or ISO 8601). Use either Duration or EndDateTime, not both." - }, - "Justification": { - "type": "string", - "description": "Reason recorded on the PIM request and in the CIPP logbook." + "Blocks": { + "type": "string" }, - "PrincipalId": { - "allOf": [ - { - "$ref": "#/components/schemas/LabelValue" - } - ], - "description": "Object id of the user, group or service principal." + "Settings": { + "type": "string" }, - "RoleDefinitionId": { - "allOf": [ - { - "$ref": "#/components/schemas/LabelValue" - } - ], - "description": "Role template id (roleDefinitionId as PIM reports it)." + "TemplateGUID": { + "type": "string" }, - "tenantFilter": { - "$ref": "#/components/schemas/LabelValue" + "TemplateName": { + "type": "string" }, - "TimeZone": { - "allOf": [ - { - "$ref": "#/components/schemas/LabelValue" - } - ], - "description": "IANA time zone of the browser (e.g. Australia/Perth); only used to word the end time in the result." + "TenantFilter": { + "type": "string" } }, "required": [ - "tenantFilter" + "TenantFilter" ] } } } }, + "parameters": [ + { + "$ref": "#/components/parameters/tenantFilter" + } + ], "responses": { "200": { "description": "Success", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/StandardResults" + "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." } } } }, - "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": [ @@ -29214,7 +30883,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Identity.Role.ReadWrite" + "x-cipp-role": "CIPP.ReportBuilder.ReadWrite" } }, "/api/ExecQuarantineManagement": { @@ -30470,9 +32139,6 @@ "ExcludeUser": { "x-cipp-field-source": "storage" }, - "GraphErrorCount": { - "x-cipp-field-source": "storage" - }, "hasAutoExtend": { "x-cipp-field-source": "storage" }, @@ -30657,6 +32323,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -30681,11 +32350,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -30716,7 +32389,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.ReadWrite" + "x-cipp-role": "CIPP.ReportBuilder.ReadWrite" } }, "/api/ExecReprocessUserLicenses": { @@ -31611,6 +33284,9 @@ "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "type": "string", "x-cipp-field-source": "storage" @@ -34507,6 +36183,9 @@ "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.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -34534,11 +36213,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -34894,7 +36577,7 @@ "tags": [ "Teams-Sharepoint" ], - "description": "Adds or removes a user in a SharePoint site role (Owners, Members or Visitors).\nGroup-connected sites manage Owners/Members through the backing M365 group via Graph;\nVisitors (and classic/communication sites entirely) are managed through the site's\nassociated SharePoint role groups via the SharePoint REST API using certificate\nauthentication. Removals sourced from ListSiteMembers carry the group and type of the\nselected entry, so users directly added to a role group on a group-connected site are\nremoved from that group rather than from the M365 group.", + "description": "Adds one or more users to, or removes a user from, a SharePoint site role (Owners, Members or Visitors).\nGroup-connected sites manage Owners/Members through the backing M365 group via Graph;\nVisitors (and classic/communication sites entirely) are managed through the site's\nassociated SharePoint role groups via the SharePoint REST API using certificate\nauthentication. Removals sourced from ListSiteMembers carry the group and type of the\nselected entry, so users directly added to a role group on a group-connected site are\nremoved from that group rather than from the M365 group.", "requestBody": { "required": true, "content": { @@ -34909,8 +36592,7 @@ "type": "string" }, "Role": { - "type": "string", - "description": "Role comes from the removal picker's selected entry when present, else from the form." + "type": "string" }, "SharePointType": { "type": "string" @@ -34919,8 +36601,7 @@ "type": "string" }, "URL": { - "type": "string", - "description": "SharePoint role group management via REST with certificate auth." + "type": "string" }, "user": { "type": "object", @@ -34929,28 +36610,21 @@ "type": "object", "properties": { "Group": { - "type": "string", - "description": "Role comes from the removal picker's selected entry when present, else from the form." + "type": "string" }, "Type": { "type": "string" } - }, - "description": "Role comes from the removal picker's selected entry when present, else from the form." + } }, "value": { "type": "string" } - }, - "required": [ - "value" - ], - "description": "Role comes from the removal picker's selected entry when present, else from the form." + } } }, "required": [ - "tenantFilter", - "URL" + "tenantFilter" ] } } @@ -35137,7 +36811,7 @@ "summary": "ExecShadowAISanction", "operationId": "ExecShadowAISanction", "tags": [ - "Tenant > Standards" + "Tenant > AI" ], "description": "Marks an AI tool from the Shadow AI catalog as company sanctioned for a tenant, or removes\nthat status. Sanctioned tools are stored per tenant in the ShadowAIConfig table and are\nreported by ListShadowAI with risk 'Informational' and status 'Sanctioned'; all other\ndetected AI tools report status 'Unsanctioned'.", "requestBody": { @@ -35198,7 +36872,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.ReadWrite" + "x-cipp-role": "Tenant.AI.ReadWrite" } }, "/api/ExecSharePointPerms": { @@ -35371,6 +37045,9 @@ "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.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -35401,11 +37078,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -35753,6 +37434,7 @@ "tags": [ "CIPP > Core" ], + "description": "Snoozes one alert item so it stops notifying. Body: CmdletName, TenantFilter, AlertItem,\nand either Duration (7, 14, 30 or 90 days) or UntilResolved=true, which keeps the snooze\nuntil the alert stops reporting the item and is then removed automatically. KeepVisible=true\nleaves the item on the dashboard, marked as snoozed, rather than hiding it. Reason is an\noptional note. There is no indefinite snooze.", "requestBody": { "required": false, "content": { @@ -35767,13 +37449,19 @@ "type": "string" }, "Duration": { - "type": "integer" + "type": "string" + }, + "KeepVisible": { + "type": "string" }, "Reason": { "type": "string" }, "TenantFilter": { "type": "string" + }, + "UntilResolved": { + "type": "string" } } } @@ -36063,6 +37751,9 @@ "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.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -36090,11 +37781,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -37189,6 +38884,9 @@ "type": "string", "x-cipp-field-source": "storage" }, + "PendingVerification": { + "x-cipp-field-source": "storage" + }, "Results": { "x-cipp-field-source": "backend" }, @@ -37367,47 +39065,10 @@ "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": { - "APIKey": { - "x-cipp-field-source": "storage" - }, - "ApplicationId": { - "x-cipp-field-source": "storage" - }, - "ApplicationSecret": { - "x-cipp-field-source": "storage" - }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "PartitionKey": { - "x-cipp-field-source": "storage" - }, - "RefreshToken": { - "x-cipp-field-source": "storage" - }, "Results": { "x-cipp-field-source": "backend" - }, - "RowKey": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "SASUrl": { - "x-cipp-field-source": "storage" - }, - "SecretValue": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "TenantId": { - "x-cipp-field-source": "storage" - }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" } } } @@ -37710,7 +39371,7 @@ "tags": [ "CIPP > Core" ], - "description": "Returns the CIPP dashboard banner notifications: any hosted maintenance notice, today's most recent entries from the alert log, and warnings for an out-of-date or misconfigured deployment.", + "description": "Returns the CIPP dashboard banner notifications: any hosted maintenance notice, a legacy infrastructure warning for instances still on Function Apps, today's most recent entries from the alert log, and warnings for an out-of-date or misconfigured deployment.", "parameters": [ { "name": "localversion", @@ -37956,7 +39617,7 @@ "summary": "ListAgent365PackageDetail", "operationId": "ListAgent365PackageDetail", "tags": [ - "Tenant > Standards" + "Tenant > AI" ], "description": "Gets the full detail for a single Microsoft Agent 365 / Copilot package by id, including the\nallowedUsersAndGroups, acquireUsersAndGroups and elementDetails that the list endpoint omits.\nUses delegated auth: this call returns 424 Failed Dependency under application context.", "parameters": [ @@ -38132,7 +39793,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.AI.Read" } }, "/api/ListAgent365Packages": { @@ -38140,7 +39801,7 @@ "summary": "ListAgent365Packages", "operationId": "ListAgent365Packages", "tags": [ - "Tenant > Standards" + "Tenant > AI" ], "description": "Lists Microsoft Agent 365 / Copilot packages (agents and Microsoft 365 apps) in the tenant\ncatalog via the Package Management API. Requires a Microsoft Agent 365 license on the tenant.\nUses delegated auth: the Package Management API currently fails under application context\n(424 Failed Dependency on GET, partial on LIST). Agents are NOT returned by the default list,\nso this also queries supportedHosts=Copilot and merges the results (deduped by id). An explicit\nOData $filter (Filter query parameter) overrides the default merge.", "parameters": [ @@ -38316,7 +39977,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.AI.Read" } }, "/api/ListAlertResults": { @@ -38326,8 +39987,24 @@ "tags": [ "CIPP > Core" ], - "description": "Lists the currently-active fired alert items for a tenant, read from the\nAlertLastRun table. AlertLastRun stores the items produced by the most recent\nrun of each scripted alert (Get-CIPPAlert*) after snoozed items have already\nbeen filtered out, so this returns the active (non-snoozed) instances. Each\nitem is returned with a content preview/hash (matching the snooze format) and\nthe raw alert item so the frontend can snooze it via ExecSnoozeAlert.", + "description": "Lists the tracked alert items for a tenant from the AlertLifecycle table, which\nWrite-AlertTrace maintains: one row per alert item with its Status (Open, Snoozed or\nResolved), when it was first and last seen, when it was last checked, how often it\nreopened and the snooze details when one applies. Pass tenantFilter (or AllTenants for\nevery tenant the caller may see). Open and Snoozed items are always returned; Resolved\nitems are included when IncludeResolved=true, limited to those resolved within the last\nDays days (default 2, at most 366). Each item carries the raw alert item and the keys\nneeded to snooze or unsnooze it.", "parameters": [ + { + "name": "Days", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "IncludeResolved", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "tenantFilter", "in": "query", @@ -38346,21 +40023,57 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields written into the storage table it reads. 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 written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "CmdletName": { + "x-cipp-field-source": "frontend" + }, + "ContentPreview": { + "x-cipp-field-source": "frontend" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" }, - "LastRunTime": { - "x-cipp-field-source": "storage" + "FirstSeen": { + "x-cipp-field-source": "frontend" }, - "PartitionKey": { + "LastChecked": { + "x-cipp-field-source": "frontend" + }, + "LastSeen": { + "x-cipp-field-source": "frontend" + }, + "ReopenCount": { + "x-cipp-field-source": "frontend" + }, + "ResolvedAt": { + "x-cipp-field-source": "frontend" + }, + "SnoozedBy": { + "x-cipp-field-source": "storage,frontend" + }, + "SnoozeReason": { + "x-cipp-field-source": "storage,frontend" + }, + "SnoozeRowKey": { "x-cipp-field-source": "storage" }, - "RowKey": { + "SnoozeUntil": { "x-cipp-field-source": "storage" }, + "SnoozeUntilResolved": { + "x-cipp-field-source": "storage,frontend" + }, + "SnoozeVisible": { + "x-cipp-field-source": "storage,frontend" + }, + "Status": { + "x-cipp-field-source": "storage,frontend" + }, + "Tenant": { + "x-cipp-field-source": "frontend" + }, "Timestamp": { "type": "string", "x-cipp-field-source": "storage" @@ -39005,6 +40718,9 @@ "AppType": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "Error": { "x-cipp-field-source": "backend" }, @@ -39035,11 +40751,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateId": { "x-cipp-field-source": "backend" }, @@ -39643,222 +41363,232 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { - "appCount": { - "x-cipp-field-source": "backend,frontend" - }, - "appNames": { - "x-cipp-field-source": "backend,frontend" - }, - "Apps": { + "appCount": { + "x-cipp-field-source": "backend,frontend" + }, + "appNames": { + "x-cipp-field-source": "backend,frontend" + }, + "Apps": { + "x-cipp-field-source": "backend" + }, + "appTypes": { + "x-cipp-field-source": "backend,frontend" + }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, + "description": { + "x-cipp-field-source": "backend,frontend" + }, + "displayName": { + "x-cipp-field-source": "backend,frontend" + }, + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "GUID": { + "x-cipp-field-source": "storage,backend" + }, + "JSON": { + "x-cipp-field-source": "storage" + }, + "Package": { + "x-cipp-field-source": "storage" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "Permissions": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "SHA": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "Source": { + "x-cipp-field-source": "storage" + }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, + "TemplateName": { + "x-cipp-field-source": "storage" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "UpdatedBy": { + "x-cipp-field-source": "storage" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Endpoint.Application.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListAssignmentFilters": { + "get": { + "summary": "ListAssignmentFilters", + "operationId": "ListAssignmentFilters", + "tags": [ + "Endpoint > MEM" + ], + "description": "Lists Intune assignment filters for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", + "parameters": [ + { + "name": "filterId", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "UseReportDB", + "in": "query", + "description": "Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants.", + "required": false, + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the Microsoft Graph entity it queries, and the columns the CIPP UI renders. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "assignmentFilterManagementType": { + "type": "object", + "x-cipp-field-source": "graph-entity,frontend" + }, + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "description": { + "type": "string", + "x-cipp-field-source": "graph-entity,frontend" + }, + "displayName": { + "type": "string", + "x-cipp-field-source": "graph-entity,frontend" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "lastModifiedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "payloads": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "platform": { + "type": "object", + "x-cipp-field-source": "graph-entity,frontend" + }, + "roleScopeTags": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "rule": { + "type": "string", + "x-cipp-field-source": "graph-entity,frontend" + } + } + } + } + } + } + }, + "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": "Endpoint.MEM.Read" + } + }, + "/api/ListAssignmentFilterTemplates": { + "get": { + "summary": "ListAssignmentFilterTemplates", + "operationId": "ListAssignmentFilterTemplates", + "tags": [ + "Endpoint > MEM" + ], + "description": "Lists the saved Intune assignment filter templates, each with its platform, filter rule and management type. Pass ID to return a single template. These are CIPP templates, not a tenant's deployed filters.", + "parameters": [ + { + "name": "ID", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "assignmentFilterManagementType": { "x-cipp-field-source": "backend" }, - "appTypes": { - "x-cipp-field-source": "backend,frontend" - }, - "description": { - "x-cipp-field-source": "backend,frontend" - }, - "displayName": { - "x-cipp-field-source": "backend,frontend" - }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "GUID": { - "x-cipp-field-source": "storage,backend" - }, - "JSON": { - "x-cipp-field-source": "storage" - }, - "Package": { - "x-cipp-field-source": "storage" - }, - "PartitionKey": { - "x-cipp-field-source": "storage" - }, - "Permissions": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "RowKey": { - "x-cipp-field-source": "storage" - }, - "SHA": { - "x-cipp-field-source": "storage" - }, - "Source": { - "x-cipp-field-source": "storage" - }, - "TemplateName": { - "x-cipp-field-source": "storage" - }, - "Timestamp": { - "type": "string", + "ContentHash": { "x-cipp-field-source": "storage" }, - "UpdatedBy": { - "x-cipp-field-source": "storage" - } - } - } - } - } - } - }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Endpoint.Application.Read", - "x-cipp-any-tenant": true - } - }, - "/api/ListAssignmentFilters": { - "get": { - "summary": "ListAssignmentFilters", - "operationId": "ListAssignmentFilters", - "tags": [ - "Endpoint > MEM" - ], - "description": "Lists Intune assignment filters for a tenant. Supports UseReportDB=true query parameter to retrieve cached data from the reporting database for significantly better performance, especially when querying AllTenants.", - "parameters": [ - { - "name": "filterId", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - }, - { - "$ref": "#/components/parameters/tenantFilter" - }, - { - "name": "UseReportDB", - "in": "query", - "description": "Serve from the reporting database cache instead of live Graph. Much faster, especially for AllTenants.", - "required": false, - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Derived from the Microsoft Graph entity it queries, and the columns the CIPP UI renders. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", - "properties": { - "assignmentFilterManagementType": { - "type": "object", - "x-cipp-field-source": "graph-entity,frontend" - }, - "createdDateTime": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "description": { - "type": "string", - "x-cipp-field-source": "graph-entity,frontend" - }, - "displayName": { - "type": "string", - "x-cipp-field-source": "graph-entity,frontend" - }, - "id": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "lastModifiedDateTime": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "payloads": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "platform": { - "type": "object", - "x-cipp-field-source": "graph-entity,frontend" - }, - "roleScopeTags": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "rule": { - "type": "string", - "x-cipp-field-source": "graph-entity,frontend" - } - } - } - } - } - } - }, - "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": "Endpoint.MEM.Read" - } - }, - "/api/ListAssignmentFilterTemplates": { - "get": { - "summary": "ListAssignmentFilterTemplates", - "operationId": "ListAssignmentFilterTemplates", - "tags": [ - "Endpoint > MEM" - ], - "description": "Lists the saved Intune assignment filter templates, each with its platform, filter rule and management type. Pass ID to return a single template. These are CIPP templates, not a tenant's deployed filters.", - "parameters": [ - { - "name": "ID", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", - "properties": { - "assignmentFilterManagementType": { - "x-cipp-field-source": "backend" - }, "description": { "x-cipp-field-source": "backend,frontend" }, @@ -39895,11 +41625,15 @@ "x-cipp-field-source": "backend" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -40201,6 +41935,9 @@ "type": "string", "x-cipp-field-source": "storage" }, + "EventCreationTime": { + "x-cipp-field-source": "storage" + }, "LogId": { "x-cipp-field-source": "backend" }, @@ -40460,7 +42197,57 @@ "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." + "description": "Derived from the Microsoft Graph entity it queries, and the columns the CIPP UI renders. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", + "properties": { + "createdDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "description": { + "type": "string", + "x-cipp-field-source": "graph-entity,frontend" + }, + "deviceEnrollmentConfigurationType": { + "type": "object", + "x-cipp-field-source": "graph-entity" + }, + "deviceNameTemplate": { + "x-cipp-field-source": "frontend" + }, + "displayName": { + "type": "string", + "x-cipp-field-source": "graph-entity,frontend" + }, + "extractHardwareHash": { + "x-cipp-field-source": "frontend" + }, + "id": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "language": { + "x-cipp-field-source": "frontend" + }, + "lastModifiedDateTime": { + "type": "string", + "x-cipp-field-source": "graph-entity" + }, + "PolicyAssignment": { + "x-cipp-field-source": "frontend" + }, + "priority": { + "type": "integer", + "x-cipp-field-source": "graph-entity" + }, + "roleScopeTagIds": { + "type": "array", + "x-cipp-field-source": "graph-entity" + }, + "version": { + "type": "integer", + "x-cipp-field-source": "graph-entity" + } + } } } } @@ -40620,9 +42407,14 @@ { "name": "DataToReturn", "in": "query", + "description": "Optional. Which section to return: 'AzureADConnectSettings' for the sync status and configuration, or 'AzureADObjectsInError' for objects that failed to sync. Omit to return both.", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "AzureADConnectSettings", + "AzureADObjectsInError" + ] } }, { @@ -40682,6 +42474,9 @@ } } }, + "400": { + "description": "Bad request - missing required field or invalid input" + }, "401": { "description": "Unauthorized - invalid or missing bearer token" }, @@ -40790,6 +42585,9 @@ "remediationPosture": { "x-cipp-field-source": "frontend" }, + "source": { + "x-cipp-field-source": "frontend" + }, "stageNames": { "x-cipp-field-source": "frontend" }, @@ -40944,18 +42742,37 @@ "x-cipp-role": "Identity.AuditLog.Read" } }, - "/api/ListBPA": { + "/api/ListBECPhishingSpread": { "get": { - "summary": "ListBPA", - "operationId": "ListBPA", + "summary": "Lists who else received mail from a sender, from message-trace metadata.", + "operationId": "ListBECPhishingSpread", "tags": [ - "Tenant > Standards" + "Identity > Administration > Users" ], - "description": "Lists Best Practice Analyser (BPA) report results for tenants, based on a selected BPA template.", + "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": "Report", + "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" @@ -40974,58 +42791,109 @@ "type": "array", "items": { "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": { - "Columns": { + "Complete": { "x-cipp-field-source": "backend" }, - "Data": { + "Days": { "x-cipp-field-source": "backend" }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "GUID": { - "x-cipp-field-source": "storage" + "ExternalCount": { + "x-cipp-field-source": "backend" }, - "JSON": { - "x-cipp-field-source": "storage" + "InternalCount": { + "x-cipp-field-source": "backend" }, - "Keys": { + "Recipients": { "x-cipp-field-source": "backend" }, - "Package": { - "x-cipp-field-source": "storage" + "Sender": { + "x-cipp-field-source": "backend" }, - "PartitionKey": { - "x-cipp-field-source": "storage" + "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": { + "post": { + "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 (the instance-wide defaults from CIPP settings).", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": {} + } + } + } + }, + "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": { + "DefaultSelected": { + "x-cipp-field-source": "backend" }, - "Permissions": { - "type": "string", - "x-cipp-field-source": "storage" + "Description": { + "x-cipp-field-source": "backend" }, - "RowKey": { - "x-cipp-field-source": "storage" + "Id": { + "x-cipp-field-source": "backend" }, - "SHA": { - "x-cipp-field-source": "storage" + "Impact": { + "x-cipp-field-source": "backend" }, - "Source": { - "x-cipp-field-source": "storage" + "Label": { + "x-cipp-field-source": "backend" }, - "Style": { + "Order": { "x-cipp-field-source": "backend" }, - "TemplateName": { - "x-cipp-field-source": "storage" + "ParameterName": { + "x-cipp-field-source": "backend" }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" + "Reversible": { + "x-cipp-field-source": "backend" }, - "UpdatedBy": { - "x-cipp-field-source": "storage" + "TargetSource": { + "x-cipp-field-source": "backend" } } } @@ -41038,6 +42906,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" } }, "security": [ @@ -41045,21 +42916,31 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.BestPracticeAnalyser.Read" + "x-cipp-role": "Identity.User.ReadWrite", + "x-cipp-any-tenant": true } }, - "/api/ListBPATemplates": { + "/api/ListBECReports": { "get": { - "summary": "ListBPATemplates", - "operationId": "ListBPATemplates", + "summary": "Lists Business Email Compromise runs.", + "operationId": "ListBECReports", "tags": [ - "Tenant > Standards" + "Identity > Administration > Users" ], - "description": "Lists available Best Practice Analyser (BPA) templates that define which checks to run.", + "description": "Lists every stored BEC run (case id, user, status, threat level and score, when it was extracted, who requested it) 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": "RawJson", + "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" @@ -41075,52 +42956,49 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. 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": { - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" + "CaseId": { + "x-cipp-field-source": "backend" }, - "GUID": { - "x-cipp-field-source": "storage" + "ContainmentRuns": { + "x-cipp-field-source": "backend" }, - "JSON": { - "x-cipp-field-source": "storage" + "DisplayName": { + "x-cipp-field-source": "backend" }, - "Name": { - "x-cipp-field-source": "frontend" + "ErrorMessage": { + "x-cipp-field-source": "backend" }, - "Package": { - "x-cipp-field-source": "storage" + "ExtractedAt": { + "x-cipp-field-source": "backend" }, - "PartitionKey": { - "x-cipp-field-source": "storage" + "IncompleteCount": { + "x-cipp-field-source": "backend" }, - "Permissions": { - "type": "string", - "x-cipp-field-source": "storage" + "Level": { + "x-cipp-field-source": "backend" }, - "RowKey": { - "x-cipp-field-source": "storage" + "RequestedAt": { + "x-cipp-field-source": "backend" }, - "SHA": { - "x-cipp-field-source": "storage" + "RequestedBy": { + "x-cipp-field-source": "backend" }, - "Source": { - "x-cipp-field-source": "storage" + "Score": { + "x-cipp-field-source": "backend" }, - "Style": { - "x-cipp-field-source": "frontend" + "Status": { + "x-cipp-field-source": "backend" }, - "TemplateName": { - "x-cipp-field-source": "storage" + "Tenant": { + "x-cipp-field-source": "backend" }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" + "UserId": { + "x-cipp-field-source": "backend" }, - "UpdatedBy": { - "x-cipp-field-source": "storage" + "UserPrincipalName": { + "x-cipp-field-source": "backend" } } } @@ -41133,6 +43011,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" } }, "security": [ @@ -41140,7 +43021,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.BestPracticeAnalyser.Read", + "x-cipp-role": "Identity.User.Read", "x-cipp-any-tenant": true } }, @@ -41292,7 +43173,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", + "x-cipp-role": "CIPP.BreachLookup.Read", "x-cipp-any-tenant": true } }, @@ -41367,7 +43248,67 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read" + "x-cipp-role": "CIPP.BreachLookup.Read" + } + }, + "/api/ListCAGapAnalysis": { + "get": { + "summary": "ListCAGapAnalysis", + "operationId": "ListCAGapAnalysis", + "tags": [ + "Tenant > SecuritySimulations" + ], + "description": "Conditional Access gap analysis for one tenant from the cached policies: findings, the\npersona-by-control coverage matrix and the 1 to 10 posture score.", + "parameters": [ + { + "$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": { + "analysis": { + "x-cipp-field-source": "backend" + }, + "analysisError": { + "x-cipp-field-source": "backend" + }, + "licensed": { + "x-cipp-field-source": "backend" + }, + "tenantFilter": { + "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": "Tenant.SecuritySimulations.Read" } }, "/api/ListCalendarPermissions": { @@ -41522,6 +43463,86 @@ "x-cipp-role": "Tenant.ConditionalAccess.Read" } }, + "/api/ListCASituations": { + "get": { + "summary": "ListCASituations", + "operationId": "ListCASituations", + "tags": [ + "Tenant > SecuritySimulations" + ], + "description": "Evaluates every predefined sign-in situation (admin, user and guest personas under unmanaged\ndevices, foreign locations, legacy clients, device-code flow, risk levels and more) live against the\ntenant's Conditional Access through the What If API, and names the control missing wherever a\nsign-in gets through. Optional adminUserId, userUserId and guestUserId pick the accounts to sign\nin as; country picks where the foreign-country sign-ins come from.", + "parameters": [ + { + "name": "country", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "$ref": "#/components/parameters/tenantFilter" + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "candidates": { + "x-cipp-field-source": "backend" + }, + "country": { + "x-cipp-field-source": "backend" + }, + "excluded": { + "x-cipp-field-source": "backend" + }, + "identities": { + "x-cipp-field-source": "backend" + }, + "licensed": { + "x-cipp-field-source": "backend" + }, + "situations": { + "x-cipp-field-source": "backend" + }, + "summary": { + "x-cipp-field-source": "backend" + }, + "tenantFilter": { + "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": "Tenant.SecuritySimulations.Read" + } + }, "/api/ListCAtemplates": { "get": { "summary": "ListCAtemplates", @@ -41567,6 +43588,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "displayName": { "x-cipp-field-source": "frontend" }, @@ -41597,11 +43621,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage,frontend" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "templateCount": { "x-cipp-field-source": "backend" }, @@ -42122,7 +44150,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", + "x-cipp-role": "CIPP.TemplateLibrary.Read", "x-cipp-any-tenant": true } }, @@ -42188,7 +44216,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", + "x-cipp-role": "CIPP.TemplateLibrary.Read", "x-cipp-any-tenant": true } }, @@ -42616,6 +44644,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "EnableSafeList": { "x-cipp-field-source": "frontend" }, @@ -42655,11 +44686,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -42884,6 +44919,9 @@ "companyName": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "displayName": { "x-cipp-field-source": "frontend" }, @@ -42920,11 +44958,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -43107,7 +45149,7 @@ "summary": "ListCopilotSettings", "operationId": "ListCopilotSettings", "tags": [ - "Tenant > Standards" + "Tenant > AI" ], "description": "Lists the Microsoft 365 Copilot admin policy settings for a tenant, one row per setting,\nwith the current raw value and a friendly state (Enabled / Disabled / Not configured).", "parameters": [ @@ -43156,7 +45198,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.AI.Read" } }, "/api/ListCopilotUsage": { @@ -43164,7 +45206,7 @@ "summary": "ListCopilotUsage", "operationId": "ListCopilotUsage", "tags": [ - "Tenant > Standards" + "Tenant > AI" ], "description": "Returns Microsoft 365 Copilot usage reports for a tenant, flattened into table rows.\nType=Adoption -> getMicrosoft365CopilotUserCountSummary (per-product enabled vs active users)\nType=Trend -> getMicrosoft365CopilotUserCountTrend (per-date active/enabled users)\nType=UserDetail-> getMicrosoft365CopilotUsageUserDetail (per-user last activity per app)", "parameters": [ @@ -43292,7 +45334,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.AI.Read" } }, "/api/ListCSPLicenses": { @@ -43864,7 +45906,7 @@ "tags": [ "CIPP > Core" ], - "description": "Retrieves cached tenant data from the CIPP reporting database (CippReportingDB). This is the fastest\nand most efficient way to query tenant data across single or multiple tenants. The database is populated\nnightly by background cache jobs, so data is typically at most 24 hours old.\n\nRequired query parameters:\n - tenantFilter: The tenant domain or 'AllTenants' to query all managed tenants.\n - type: The cache collection to retrieve (e.g. Users, Groups, Mailboxes, Devices, etc.).\n Not required when countsOnly=true.\n\nOptional query parameters:\n - countsOnly: When 'true', returns one row per tenant per collection containing only the record\n count and the time that collection was last cached. This reads the pre-computed\n '-Count' rows, so it is a single table query regardless of tenant count and\n never materializes the underlying records. Combine with tenantFilter=AllTenants to\n get an estate-wide inventory and per-tenant cache freshness in one call. Pass a\n type alongside it to restrict the result to a single collection.\n\nUse type=_availableTypes to discover which cache collections exist for a given tenant. Omitting the\ntype parameter also returns the available types.\n\nPERFORMANCE GUIDANCE: For AllTenants queries or any bulk/cross-tenant data retrieval, prefer\nListDBCache over calling individual endpoints (e.g. ListUsers, ListGroups, ListMailboxes) directly.\nIndividual endpoints make live API calls per tenant which is significantly slower and may hit\nthrottling limits. ListDBCache reads pre-cached data from Azure Table Storage and returns results\nin seconds regardless of tenant count.\n\nNote that tenantFilter=AllTenants WITH a type performs a cross-partition scan and materializes every\nrecord of that collection across every tenant, so it grows with the size of the estate. Where only\ntotals are needed, countsOnly=true is dramatically cheaper and should always be preferred.\n\nRecommended workflow for MCP tool selection:\n 1. Call ListDBCache with type=_availableTypes to discover available cache collections.\n 2. If the data you need exists as a cache type, use ListDBCache with that type.\n 3. Only fall back to individual List* endpoints when you need real-time data for a single tenant\n or when the data is not available in the cache.\n\nCommon cache types include: Users, Groups, Mailboxes, Devices, ConditionalAccess, Applications,\nIntunePolicy, CompliancePolicy, and many more. The exact set depends on what has been configured.", + "description": "Retrieves cached tenant data from the CIPP reporting database (CippReportingDB). This is the fastest\nand most efficient way to query tenant data across single or multiple tenants. The database is populated\nnightly by background cache jobs, so data is typically at most 24 hours old.\n\nRequired query parameters:\n - tenantFilter: The tenant domain or 'AllTenants' to query all managed tenants.\n\nOptional query parameters:\n - type: The cache collection to retrieve (e.g. Users, Groups, Mailboxes, Devices, etc.).\n Omit it (or pass type=_availableTypes) to get the list of collections for the tenant\n instead of records; it is also not needed when countsOnly=true.\n - countsOnly: When 'true', returns one row per tenant per collection containing only the record\n count and the time that collection was last cached. This reads the pre-computed\n '-Count' rows, so it is a single table query regardless of tenant count and\n never materializes the underlying records. Combine with tenantFilter=AllTenants to\n get an estate-wide inventory and per-tenant cache freshness in one call. Pass a\n type alongside it to restrict the result to a single collection.\n - select: Comma-separated list of top-level fields to keep on each record (e.g.\n select=id,displayName,userPrincipalName). Everything else is dropped during parse,\n shrinking the response. A kept field keeps its ENTIRE subtree, so select=conditions\n keeps conditions.users.includeRoles too; projection never reaches inside a kept\n value. The owning Tenant is always stamped on each record regardless of select.\n - top: Return at most this many records. For tenantFilter=AllTenants this is a GLOBAL cap\n across all tenants when ungrouped, so use it to sample rather than to page. When\n groupBy=Tenant is also set, top instead caps the records within each tenant bucket.\n - latestOnly: When 'true', keep only the newest record per tenant, ranked by dateField (or\n an auto-detected date field such as createdDateTime). Collapses per-day\n snapshot types like SecureScore to one current row per tenant.\n - groupBy: Set to 'Tenant' to return one bucket per tenant as\n { Tenant, Count, Records } objects instead of a flat record list.\n - dateField: The record field latestOnly ranks by. Omit to auto-detect.\n\nUse type=_shape for every collection's row count and fields (recorded when the cache was written).\nUse type=_availableTypes to discover which cache collections exist for a given tenant. Omitting the\ntype parameter also returns the available types.\n\nPERFORMANCE GUIDANCE: For AllTenants queries or any bulk/cross-tenant data retrieval, prefer\nListDBCache over calling individual endpoints (e.g. ListUsers, ListGroups, ListMailboxes) directly.\nIndividual endpoints make live API calls per tenant which is significantly slower and may hit\nthrottling limits. ListDBCache reads pre-cached data from Azure Table Storage and returns results\nin seconds regardless of tenant count.\n\nNote that tenantFilter=AllTenants WITH a type performs a cross-partition scan and materializes every\nrecord of that collection across every tenant, so it grows with the size of the estate. Where only\ntotals are needed, countsOnly=true is dramatically cheaper and should always be preferred.\n\nRecommended workflow for MCP tool selection:\n 1. Call ListDBCache with type=_availableTypes to discover available cache collections.\n 2. If the data you need exists as a cache type, use ListDBCache with that type.\n 3. Only fall back to individual List* endpoints when you need real-time data for a single tenant\n or when the data is not available in the cache.\n\nCommon cache types include: Users, Groups, Mailboxes, Devices, ConditionalAccess, Applications,\nIntunePolicy, CompliancePolicy, and many more. The exact set depends on what has been configured.", "parameters": [ { "name": "countsOnly", @@ -43874,6 +45916,42 @@ "type": "boolean" } }, + { + "name": "dateField", + "in": "query", + "description": "The record date field latestOnly ranks by. Omit to auto-detect (createdDateTime, lastRefresh, etc).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "groupBy", + "in": "query", + "description": "Group the result into one bucket per tenant. Only 'Tenant' is supported.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "latestOnly", + "in": "query", + "description": "When true, keep only the newest record per tenant (by dateField, or an auto-detected date field). Collapses e.g. SecureScore's per-day snapshots to one current row per tenant.", + "required": false, + "schema": { + "type": "boolean" + } + }, + { + "name": "select", + "in": "query", + "description": "Comma-separated list of top-level fields to keep on each record; everything else is dropped during parse. A kept field keeps its ENTIRE subtree (e.g. select=conditions keeps conditions.users.includeRoles). The Tenant stamp is always preserved. Omit to return all fields.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "tenantFilter", "in": "query", @@ -43882,10 +45960,19 @@ "type": "string" } }, + { + "name": "top", + "in": "query", + "description": "Return at most this many records. For AllTenants this is a global cap across all tenants, unless groupBy=Tenant is set, in which case it caps records within each tenant bucket.", + "required": false, + "schema": { + "type": "integer" + } + }, { "name": "type", "in": "query", - "required": true, + "required": false, "schema": { "type": "string" } @@ -45351,6 +47438,9 @@ "comments": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "Enabled": { "x-cipp-field-source": "frontend" }, @@ -45384,11 +47474,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -46035,6 +48129,9 @@ "cippconnectortype": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -46062,11 +48159,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -47386,7 +49487,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read" + "x-cipp-role": "CIPP.ReportBuilder.Read" } }, "/api/ListGenericTestFunction": { @@ -47683,14 +49784,22 @@ { "name": "period", "in": "query", + "description": "Reporting window for Graph reports: 'D7', 'D30' (default), 'D90' or 'D180'.", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "D180", + "D30", + "D7", + "D90" + ] } }, { "name": "report", "in": "query", + "description": "The report name to fetch (e.g. 'getEmailActivityUserDetail'). Omit to enumerate the reports available for the selected Type instead of fetching one.", "required": false, "schema": { "type": "string" @@ -47702,9 +49811,14 @@ { "name": "type", "in": "query", + "description": "Report source: 'graph' (default) for Microsoft Graph usage reports, or 'office' for the reports.office.com service.", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "graph", + "office" + ] } } ], @@ -47762,7 +49876,7 @@ "tags": [ "CIPP > Core" ], - "description": "Proxies an arbitrary Microsoft Graph API GET request for a tenant. Supports custom endpoints, filters, pagination, and field selection via query parameters.", + "description": "Proxies an arbitrary Microsoft Graph API GET request for a tenant. Supports custom endpoints, filters, pagination, and field selection via query parameters. If a request returns a permission error, retry with AsApp set to true: admin and application-scoped endpoints (for example admin/sharepoint/settings) require the application's own permissions rather than the default delegated access.", "parameters": [ { "name": "$count", @@ -47980,9 +50094,14 @@ { "name": "Version", "in": "query", + "description": "Graph API version to call: v1.0 or beta. Defaults to beta when omitted.", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "beta", + "v1.0" + ] } } ], @@ -48442,6 +50561,9 @@ "allowExternal": { "x-cipp-field-source": "backend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "description": { "x-cipp-field-source": "backend,frontend" }, @@ -48487,11 +50609,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage,backend" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -49557,6 +51683,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "description": { "x-cipp-field-source": "backend,frontend" }, @@ -49593,11 +51722,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -49753,6 +51886,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "corrupt": { "x-cipp-field-source": "backend" }, @@ -49792,11 +51928,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage,backend,frontend" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "templateCount": { "x-cipp-field-source": "backend" }, @@ -49851,7 +51991,7 @@ "tags": [ "CIPP > Settings" ], - "description": "Lists trusted IP addresses configured in CIPP for IP-based access control.", + "description": "Lists CIPP's IP allow/block list: every tenant or AllTenants entry with its address or CIDR range (Range) and state (Trusted, Blocked or NotTrusted).", "responses": { "200": { "description": "Success", @@ -49879,7 +52019,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", + "x-cipp-role": "CIPP.IPDatabase.Read", "x-cipp-any-tenant": true } }, @@ -49913,6 +52053,12 @@ "AzureRMSLicensingEnabled": { "x-cipp-field-source": "backend" }, + "DecryptAttachmentForEncryptOnly": { + "x-cipp-field-source": "backend" + }, + "EnablePdfEncryption": { + "x-cipp-field-source": "backend" + }, "ExternalLicensingEnabled": { "x-cipp-field-source": "backend" }, @@ -49928,9 +52074,15 @@ "MessageEncryptionEnabled": { "x-cipp-field-source": "backend" }, + "SimplifiedClientAccessDoNotForwardDisabled": { + "x-cipp-field-source": "backend" + }, "SimplifiedClientAccessEnabled": { "x-cipp-field-source": "backend" }, + "SimplifiedClientAccessEncryptOnlyDisabled": { + "x-cipp-field-source": "backend" + }, "TransportDecryptionSetting": { "x-cipp-field-source": "backend" } @@ -50045,6 +52197,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "defaultDuration.label": { "x-cipp-field-source": "frontend" }, @@ -50090,11 +52245,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "templateName": { "x-cipp-field-source": "storage,frontend" }, @@ -50165,23 +52324,237 @@ "bearerAuth": [] } ], - "x-cipp-role": "Identity.Role.Read", + "x-cipp-role": "Identity.Role.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListJITRoleTemplates": { + "get": { + "summary": "ListJITRoleTemplates", + "operationId": "ListJITRoleTemplates", + "tags": [ + "Identity > Administration > Users" + ], + "description": "Lists JIT Role Templates - named allow-lists of directory roles used to restrict which roles a\nCIPP custom role may assign via JIT Admin.", + "parameters": [ + { + "name": "GUID", + "in": "query", + "description": "If a specific GUID is requested, filter to that template", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", + "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, + "createdBy": { + "x-cipp-field-source": "frontend" + }, + "createdDate": { + "x-cipp-field-source": "frontend" + }, + "ETag": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "GUID": { + "x-cipp-field-source": "storage" + }, + "JSON": { + "x-cipp-field-source": "storage" + }, + "Package": { + "x-cipp-field-source": "storage" + }, + "PartitionKey": { + "x-cipp-field-source": "storage" + }, + "Permissions": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "roles": { + "x-cipp-field-source": "frontend" + }, + "RowKey": { + "x-cipp-field-source": "storage" + }, + "SHA": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "Source": { + "x-cipp-field-source": "storage" + }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, + "templateName": { + "x-cipp-field-source": "storage,frontend" + }, + "Timestamp": { + "type": "string", + "x-cipp-field-source": "storage" + }, + "UpdatedBy": { + "x-cipp-field-source": "storage" + } + } + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Identity.Role.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListKnownIPDb": { + "get": { + "summary": "ListKnownIPDb", + "operationId": "ListKnownIPDb", + "tags": [ + "CIPP > Core" + ], + "description": "Lists known IP address entries from the CIPP IP database, optionally filtered by tenant.", + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "CIPP.IPDatabase.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListLicenseOptimization": { + "get": { + "summary": "ListLicenseOptimization", + "operationId": "ListLicenseOptimization", + "tags": [ + "Tenant > Reports" + ], + "description": "License cost-optimization report for a tenant: a monetary summary plus reclaim\nopportunities across five waste tiers (unassigned seats, disabled and inactive licensed\naccounts, mailbox-only downgrade candidates, and redundant overlapping SKUs). Computed from\nthe reporting-DB cache. For tenantFilter=AllTenants it returns a per-tenant summary money\nmap (ranked by reclaimable spend) instead of the full opportunity detail.", + "parameters": [ + { + "name": "currency", + "in": "query", + "description": "Currency the money figures are resolved in (ISO code); defaults to USD", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "inactiveDays", + "in": "query", + "description": "Sign-in age in days past which an enabled licensed user counts as inactive (default 90)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "tenantFilter", + "in": "query", + "description": "The tenant to report on, or AllTenants for the cross-tenant summary money map", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." + } + } + } + } + }, + "401": { + "description": "Unauthorized - invalid or missing bearer token" + }, + "403": { + "description": "Forbidden - caller lacks the required RBAC role" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Directory.Read", "x-cipp-any-tenant": true } }, - "/api/ListJITRoleTemplates": { + "/api/ListLicensePricing": { "get": { - "summary": "ListJITRoleTemplates", - "operationId": "ListJITRoleTemplates", + "summary": "ListLicensePricing", + "operationId": "ListLicensePricing", "tags": [ - "Identity > Administration > Users" + "Tenant > Reports" ], - "description": "Lists JIT Role Templates - named allow-lists of directory roles used to restrict which roles a\nCIPP custom role may assign via JIT Admin.", + "description": "Lists the resolved monthly price for every known license SKU: MSP price overrides merged\nover the shipped MSRP estimates. Consumed by the license optimization report and its\nprice-management UI. Each row carries a Source of Override, Estimate, or Unknown.\n\nPrices are resolved in the requested currency (?currency=, default USD). The response also\ncarries the list of currencies present in the price data so the UI can offer a selector.", "parameters": [ { - "name": "GUID", + "name": "currency", "in": "query", - "description": "If a specific GUID is requested, filter to that template", + "description": "Currency to resolve prices in (ISO code); defaults to USD", "required": false, "schema": { "type": "string" @@ -50197,55 +52570,25 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. 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 columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", "properties": { - "createdBy": { + "Currency": { "x-cipp-field-source": "frontend" }, - "createdDate": { + "MonthlyPrice": { "x-cipp-field-source": "frontend" }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "GUID": { - "x-cipp-field-source": "storage" - }, - "JSON": { - "x-cipp-field-source": "storage" - }, - "Package": { - "x-cipp-field-source": "storage" - }, - "PartitionKey": { - "x-cipp-field-source": "storage" - }, - "Permissions": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "roles": { + "Product_Display_Name": { "x-cipp-field-source": "frontend" }, - "RowKey": { - "x-cipp-field-source": "storage" + "skuId": { + "x-cipp-field-source": "frontend" }, - "SHA": { - "x-cipp-field-source": "storage" + "skuPartNumber": { + "x-cipp-field-source": "frontend" }, "Source": { - "x-cipp-field-source": "storage" - }, - "templateName": { - "x-cipp-field-source": "storage,frontend" - }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "UpdatedBy": { - "x-cipp-field-source": "storage" + "x-cipp-field-source": "frontend" } } } @@ -50258,45 +52601,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Identity.Role.Read", - "x-cipp-any-tenant": true - } - }, - "/api/ListKnownIPDb": { - "get": { - "summary": "ListKnownIPDb", - "operationId": "ListKnownIPDb", - "tags": [ - "CIPP > Core" - ], - "description": "Lists known IP address entries from the CIPP IP database, optionally filtered by tenant.", - "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": [ @@ -50304,18 +52611,18 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", + "x-cipp-role": "Tenant.Directory.Read", "x-cipp-any-tenant": true } }, - "/api/ListLicenseOptimization": { + "/api/ListLicenseRecommendations": { "get": { - "summary": "ListLicenseOptimization", - "operationId": "ListLicenseOptimization", + "summary": "ListLicenseRecommendations", + "operationId": "ListLicenseRecommendations", "tags": [ "Tenant > Reports" ], - "description": "License cost-optimization report for a tenant: a monetary summary plus reclaim\nopportunities across five waste tiers (unassigned seats, disabled and inactive licensed\naccounts, mailbox-only downgrade candidates, and redundant overlapping SKUs). Computed from\nthe reporting-DB cache. For tenantFilter=AllTenants it returns a per-tenant summary money\nmap (ranked by reclaimable spend) instead of the full opportunity detail.", + "description": "License recommendation report for a tenant: the waste tiers from ListLicenseOptimization\nplus evidence-based downgrade targets (from 90-day usage reports against the license\ncatalog), consolidation and protection upgrades, and the recommended annual/monthly\ncommitment split per SKU. Feeds the License Optimization page and the client PDF.", "parameters": [ { "name": "currency", @@ -50329,66 +52636,51 @@ { "name": "inactiveDays", "in": "query", - "description": "Sign-in age in days past which an enabled licensed user counts as inactive (default 90)", + "description": "Sign-in / activity age in days past which a user counts as inactive (default 90)", "required": false, "schema": { "type": "string" } }, { - "name": "tenantFilter", + "name": "protectSecurityFeatures", "in": "query", - "description": "The tenant to report on, or AllTenants for the cross-tenant summary money map", - "required": true, + "required": false, "schema": { "type": "string" } - } - ], - "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." - } - } - } + }, + { + "name": "recommendDowngrades", + "in": "query", + "required": false, + "schema": { + "type": "string" } }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" + { + "name": "recommendTerms", + "in": "query", + "required": false, + "schema": { + "type": "string" + } }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ { - "bearerAuth": [] - } - ], - "x-cipp-role": "Tenant.Directory.Read", - "x-cipp-any-tenant": true - } - }, - "/api/ListLicensePricing": { - "get": { - "summary": "ListLicensePricing", - "operationId": "ListLicensePricing", - "tags": [ - "Tenant > Reports" - ], - "description": "Lists the resolved monthly price for every known license SKU: MSP price overrides merged\nover the shipped MSRP estimates. Consumed by the license optimization report and its\nprice-management UI. Each row carries a Source of Override, Estimate, or Unknown.\n\nPrices are resolved in the requested currency (?currency=, default USD). The response also\ncarries the list of currencies present in the price data so the UI can offer a selector.", - "parameters": [ + "name": "recommendUpgrades", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, { - "name": "currency", + "$ref": "#/components/parameters/tenantFilter" + }, + { + "name": "tenureMonths", "in": "query", - "description": "Currency to resolve prices in (ISO code); defaults to USD", + "description": "Months a seat must have been assigned to count as stable for an annual commitment (default 6)", "required": false, "schema": { "type": "string" @@ -50406,22 +52698,19 @@ "type": "object", "description": "Derived from the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", "properties": { - "Currency": { - "x-cipp-field-source": "frontend" - }, - "MonthlyPrice": { + "License": { "x-cipp-field-source": "frontend" }, - "Product_Display_Name": { + "Reason": { "x-cipp-field-source": "frontend" }, - "skuId": { + "Suggestion": { "x-cipp-field-source": "frontend" }, - "skuPartNumber": { + "Type": { "x-cipp-field-source": "frontend" }, - "Source": { + "User": { "x-cipp-field-source": "frontend" } } @@ -50435,9 +52724,6 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" - }, - "500": { - "description": "Internal server error" } }, "security": [ @@ -50445,8 +52731,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Directory.Read", - "x-cipp-any-tenant": true + "x-cipp-role": "Tenant.Directory.Read" } }, "/api/ListLicenses": { @@ -50628,7 +52913,7 @@ "tags": [ "CIPP > Core" ], - "description": "Lists CIPP platform audit logs with filtering by severity, date range, tenant, and user. Supports listing available log categories, fetching a single entry, and server-side pagination via manualPagination/nextLink.", + "description": "Lists CIPP platform audit logs with filtering by severity, date range, tenant, and user. Supports listing available log categories, fetching a single entry, and server-side pagination via manualPagination/nextLink. Pass Search to filter rows by a case-insensitive substring of the log Message. Pass summaryOnly=true (or countsOnly=true) to get per-API and per-severity counts across the requested range with the heavy LogData dropped, instead of the full rows.", "parameters": [ { "name": "API", @@ -50646,6 +52931,15 @@ "type": "string" } }, + { + "name": "countsOnly", + "in": "query", + "description": "summaryOnly/countsOnly: return per-API and per-severity counts for the matching rows across the requested date range, dropping the (often huge) LogData payloads entirely. Ignores pagination.", + "required": false, + "schema": { + "type": "boolean" + } + }, { "name": "DateFilter", "in": "query", @@ -50718,7 +53012,7 @@ { "name": "PageSize", "in": "query", - "description": "Rows to return per page, clamped between 50 and 1000. Defaults to 400.", + "description": "Rows to return per page, clamped between 50 and 5000. Defaults to 2000.", "required": false, "schema": { "type": "integer" @@ -50732,6 +53026,15 @@ "type": "string" } }, + { + "name": "Search", + "in": "query", + "description": "Free-text search over the log Message (case-insensitive literal substring). Applied client-side per partition alongside the other row filters, and independent of the Filter flag.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "Severity", "in": "query", @@ -50756,6 +53059,15 @@ "type": "string" } }, + { + "name": "summaryOnly", + "in": "query", + "description": "summaryOnly/countsOnly: return per-API and per-severity counts for the matching rows across the requested date range, dropping the (often huge) LogData payloads entirely. Ignores pagination.", + "required": false, + "schema": { + "type": "boolean" + } + }, { "name": "Tenant", "in": "query", @@ -50790,6 +53102,9 @@ "AppId": { "x-cipp-field-source": "backend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "DateFilter": { "x-cipp-field-source": "backend" }, @@ -50832,11 +53147,15 @@ "x-cipp-field-source": "backend" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "Standard": { "x-cipp-field-source": "backend" }, @@ -50880,7 +53199,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read", + "x-cipp-role": "CIPP.Logs.Read", "x-cipp-any-tenant": true } }, @@ -52599,6 +54918,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -52623,11 +54945,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "templateName": { "x-cipp-field-source": "storage" }, @@ -52709,6 +55035,9 @@ "PartitionKey": { "x-cipp-field-source": "storage" }, + "PSAConsolidationKey": { + "x-cipp-field-source": "storage" + }, "PsaTicketId": { "x-cipp-field-source": "storage" }, @@ -52987,17 +55316,17 @@ }, "/api/ListOffboardingProgress": { "get": { - "summary": "Get the live progress of an offboarding job", + "summary": "Get the live progress of an offboarding or BEC remediation job", "operationId": "ListOffboardingProgress", "tags": [ "Identity > Administration > Users" ], - "description": "Returns the progress rows of an offboarding job started from the wizard: one row per user with\nits overall status and the status and message of every step. Same rows as ListAsyncDeployment.\nThis is a read-only GET, so it carries a read role; an offboarding operator (who holds the\nbroader user write role) can still follow and re-run their jobs.", + "description": "Returns the progress rows of an offboarding job started from the wizard, or of a background BEC\nremediation (ExecBECRemediate with Async): one row per user with its overall status and the\nstatus and message of every step. Same rows as ListAsyncDeployment.\nThis is a read-only GET, so it carries a read role; an offboarding operator (who holds the\nbroader user write role) can still follow and re-run their jobs.", "parameters": [ { "name": "DeploymentId", "in": "query", - "description": "The DeploymentId handed back by ExecOffboardUser, also stored on each offboarding task", + "description": "The DeploymentId handed back by ExecOffboardUser (also stored on each offboarding task) or ExecBECRemediate", "required": true, "schema": { "type": "string" @@ -53650,6 +55979,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -53698,11 +56030,15 @@ "x-cipp-field-source": "frontend" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "templateName": { "x-cipp-field-source": "storage,frontend" }, @@ -53911,6 +56247,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "CustomCount": { "x-cipp-field-source": "frontend" }, @@ -53944,11 +56283,15 @@ "x-cipp-field-source": "frontend" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -53983,7 +56326,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "CIPP.Core.Read" + "x-cipp-role": "CIPP.ReportBuilder.Read" } }, "/api/ListResellerRelationshipLink": { @@ -54201,6 +56544,9 @@ "comments": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "Enabled": { "x-cipp-field-source": "frontend" }, @@ -54234,11 +56580,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -55067,6 +57417,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -55091,11 +57444,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateDescription": { "x-cipp-field-source": "frontend" }, @@ -55607,6 +57964,9 @@ "comments": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "Description": { "x-cipp-field-source": "frontend" }, @@ -55637,11 +57997,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -55784,6 +58148,9 @@ "Comment": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ContentType": { "x-cipp-field-source": "frontend" }, @@ -55820,11 +58187,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -55942,7 +58313,7 @@ "summary": "ListShadowAI", "operationId": "ListShadowAI", "tags": [ - "Tenant > Standards" + "Tenant > AI" ], "description": "Compiles a Shadow AI overview for a tenant by matching CACHED data from the CIPP reporting\ndatabase (DetectedApps, ServicePrincipals, OAuth2PermissionGrants) against the curated AI\ncatalog (Config/ShadowAI.json). No live Graph enumeration is performed - refresh the data by\nsyncing those caches (ExecCIPPDBCache). The only live call is a bounded, best-effort 7-day\nsign-in lookup for the matched AI applications.", "parameters": [ @@ -55959,359 +58330,7 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the Microsoft Graph entity it queries, and the fields the endpoint selects onto each record. This endpoint returns the Graph response as-is without selecting fields, so these are the properties the entity CAN carry (x-cipp-field-source: graph-entity) rather than a proven projection - Graph returns a default subset unless asked otherwise.", - "properties": { - "agent": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "aiToolsDetected": { - "x-cipp-field-source": "backend" - }, - "appDisplayName": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "appId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "appliedConditionalAccessPolicies": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "appliedEventListeners": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "appOwnerTenantId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "appTokenProtectionStatus": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "authenticationAppDeviceDetails": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "authenticationAppPolicyEvaluationDetails": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "authenticationContextClassReferences": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "authenticationDetails": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "authenticationMethodsUsed": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "authenticationProcessingDetails": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "authenticationProtocol": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "authenticationRequirement": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "authenticationRequirementPolicies": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "autonomousSystemNumber": { - "type": "integer", - "x-cipp-field-source": "graph-entity" - }, - "azureResourceId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "byCategory": { - "x-cipp-field-source": "backend" - }, - "byRisk": { - "x-cipp-field-source": "backend" - }, - "clientAppUsed": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "clientCredentialType": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "clientSessionId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "conditionalAccessAudiences": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "conditionalAccessStatus": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "consentedAiApps": { - "x-cipp-field-source": "backend" - }, - "consentedApps": { - "x-cipp-field-source": "backend" - }, - "correlationId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "createdDateTime": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "crossTenantAccessType": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "detectedApps": { - "x-cipp-field-source": "backend" - }, - "deviceDetail": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "deviceInstalls": { - "x-cipp-field-source": "backend" - }, - "entraSynced": { - "x-cipp-field-source": "backend" - }, - "federatedCredentialId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "flaggedForReview": { - "type": "boolean", - "x-cipp-field-source": "graph-entity" - }, - "globalSecureAccessIpAddress": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "highRiskTools": { - "x-cipp-field-source": "backend" - }, - "homeTenantId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "homeTenantName": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "id": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "incomingTokenType": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "intuneSynced": { - "x-cipp-field-source": "backend" - }, - "ipAddress": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "ipAddressFromResourceProvider": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "isInteractive": { - "type": "boolean", - "x-cipp-field-source": "graph-entity" - }, - "isTenantRestricted": { - "type": "boolean", - "x-cipp-field-source": "graph-entity" - }, - "isThroughGlobalSecureAccess": { - "type": "boolean", - "x-cipp-field-source": "graph-entity" - }, - "lastDataRefresh": { - "x-cipp-field-source": "backend" - }, - "location": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "managedServiceIdentity": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "mfaDetail": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "networkLocationDetails": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "originalRequestId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "originalTransferMethod": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "privateLinkDetails": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "processingTimeInMilliseconds": { - "type": "integer", - "x-cipp-field-source": "graph-entity" - }, - "resourceDisplayName": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "resourceId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "resourceOwnerTenantId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "resourceServicePrincipalId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "resourceTenantId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "riskDetail": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "riskEventTypes_v2": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "riskLevelAggregated": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "riskLevelDuringSignIn": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "riskState": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "sanctionedTools": { - "x-cipp-field-source": "backend" - }, - "servicePrincipalCredentialKeyId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "servicePrincipalCredentialThumbprint": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "servicePrincipalId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "servicePrincipalName": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "sessionId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "sessionLifetimePolicies": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "signInEventTypes": { - "type": "array", - "x-cipp-field-source": "graph-entity" - }, - "signInIdentifier": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "signInIdentifierType": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "signInTokenProtectionStatus": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "status": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "summary": { - "x-cipp-field-source": "backend" - }, - "tokenIssuerName": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "tokenIssuerType": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "tokenProtectionStatusDetails": { - "type": "object", - "x-cipp-field-source": "graph-entity" - }, - "topTools": { - "x-cipp-field-source": "backend" - }, - "uniqueTokenIdentifier": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "userAgent": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "userDisplayName": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "userId": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "userPrincipalName": { - "type": "string", - "x-cipp-field-source": "graph-entity" - }, - "userType": { - "type": "object", - "x-cipp-field-source": "graph-entity" - } - } + "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." } } } @@ -56329,7 +58348,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.Standards.Read" + "x-cipp-role": "Tenant.AI.Read" } }, "/api/ListSharedMailboxAccountEnabled": { @@ -56591,78 +58610,7 @@ "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": { - "assignments": { - "x-cipp-field-source": "backend" - }, - "broadClaimGrants": { - "x-cipp-field-source": "backend" - }, - "byBroadClaim": { - "x-cipp-field-source": "backend" - }, - "byPermissionLevel": { - "x-cipp-field-source": "backend" - }, - "byPrincipalType": { - "x-cipp-field-source": "backend" - }, - "claim": { - "x-cipp-field-source": "backend" - }, - "directFullControlGrants": { - "x-cipp-field-source": "backend" - }, - "externalGrants": { - "x-cipp-field-source": "backend" - }, - "grants": { - "x-cipp-field-source": "backend" - }, - "lastDataRefresh": { - "x-cipp-field-source": "backend" - }, - "level": { - "x-cipp-field-source": "backend" - }, - "libraries": { - "x-cipp-field-source": "backend" - }, - "librariesScanned": { - "x-cipp-field-source": "backend" - }, - "permissionsSynced": { - "x-cipp-field-source": "backend" - }, - "site": { - "x-cipp-field-source": "backend" - }, - "sitesScanned": { - "x-cipp-field-source": "backend" - }, - "sitesSkipped": { - "x-cipp-field-source": "backend" - }, - "skippedSites": { - "x-cipp-field-source": "backend" - }, - "summary": { - "x-cipp-field-source": "backend" - }, - "topSitesByUniqueLibraries": { - "x-cipp-field-source": "backend" - }, - "totalAssignments": { - "x-cipp-field-source": "backend" - }, - "type": { - "x-cipp-field-source": "backend" - }, - "uniquePermissionLibraries": { - "x-cipp-field-source": "backend" - } - } + "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." } } } @@ -56692,6 +58640,14 @@ ], "description": "Retrieves SharePoint Online storage quota usage for a tenant, showing used and total storage.", "parameters": [ + { + "name": "live", + "in": "query", + "required": false, + "schema": { + "type": "boolean" + } + }, { "$ref": "#/components/parameters/tenantFilter" } @@ -56705,7 +58661,24 @@ "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." + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "Dashboard": { + "x-cipp-field-source": "backend" + }, + "GeoLocations": { + "x-cipp-field-source": "backend" + }, + "GeoUsedStorageMB": { + "x-cipp-field-source": "backend" + }, + "Percentage": { + "x-cipp-field-source": "backend" + }, + "TenantStorageMB": { + "x-cipp-field-source": "backend" + } + } } } } @@ -56716,6 +58689,9 @@ }, "403": { "description": "Forbidden - caller lacks the required RBAC role" + }, + "500": { + "description": "Internal server error" } }, "security": [ @@ -56913,111 +58889,7 @@ "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": { - "anonymousEditLinks": { - "x-cipp-field-source": "backend" - }, - "anonymousLinks": { - "x-cipp-field-source": "backend" - }, - "byLinkType": { - "x-cipp-field-source": "backend" - }, - "byScope": { - "x-cipp-field-source": "backend" - }, - "externalLinks": { - "x-cipp-field-source": "backend" - }, - "externalRecipients": { - "x-cipp-field-source": "backend" - }, - "folderShares": { - "x-cipp-field-source": "backend" - }, - "internalLinks": { - "x-cipp-field-source": "backend" - }, - "itemsShared": { - "x-cipp-field-source": "backend" - }, - "lastDataRefresh": { - "x-cipp-field-source": "backend" - }, - "library": { - "x-cipp-field-source": "backend" - }, - "links": { - "x-cipp-field-source": "backend" - }, - "linksSynced": { - "x-cipp-field-source": "backend" - }, - "neverExpiringAnonymous": { - "x-cipp-field-source": "backend" - }, - "oneDriveAccounts": { - "x-cipp-field-source": "backend" - }, - "oneDriveFiles": { - "x-cipp-field-source": "backend" - }, - "oneDriveStorageUsedGB": { - "x-cipp-field-source": "backend" - }, - "passwordProtectedLinks": { - "x-cipp-field-source": "backend" - }, - "recipient": { - "x-cipp-field-source": "backend" - }, - "scope": { - "x-cipp-field-source": "backend" - }, - "sharePointFiles": { - "x-cipp-field-source": "backend" - }, - "sharePointSites": { - "x-cipp-field-source": "backend" - }, - "sharePointStorageUsedGB": { - "x-cipp-field-source": "backend" - }, - "site": { - "x-cipp-field-source": "backend" - }, - "summary": { - "x-cipp-field-source": "backend" - }, - "teamsFiles": { - "x-cipp-field-source": "backend" - }, - "teamsSites": { - "x-cipp-field-source": "backend" - }, - "teamsStorageUsedGB": { - "x-cipp-field-source": "backend" - }, - "topLibraries": { - "x-cipp-field-source": "backend" - }, - "topRecipients": { - "x-cipp-field-source": "backend" - }, - "topSites": { - "x-cipp-field-source": "backend" - }, - "totalLinks": { - "x-cipp-field-source": "backend" - }, - "type": { - "x-cipp-field-source": "backend" - }, - "usageSynced": { - "x-cipp-field-source": "backend" - } - } + "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." } } } @@ -57057,6 +58929,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "Error": { "x-cipp-field-source": "backend" }, @@ -57087,6 +58962,7 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "SiteTemplateCount": { @@ -57095,6 +58971,9 @@ "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateId": { "x-cipp-field-source": "backend" }, @@ -57547,9 +59426,14 @@ { "name": "Type", "in": "query", + "description": "Optional site-type filter: 'SharePoint' or 'TeamsSite'. Omit to return both.", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "SharePoint", + "TeamsSite" + ] } } ], @@ -58177,9 +60061,14 @@ { "name": "Type", "in": "query", + "description": "Required. Which usage set to list: SharePointSiteUsage or OneDriveUsageAccount.", "required": false, "schema": { - "type": "string" + "type": "string", + "enum": [ + "OneDriveUsageAccount", + "SharePointSiteUsage" + ] } }, { @@ -58409,7 +60298,7 @@ "tags": [ "CIPP > Core" ], - "description": "Lists alerts that have been snoozed (temporarily suppressed), filterable by cmdlet name. Returns snooze duration and scope details.", + "description": "Lists alerts that have been snoozed (temporarily suppressed), filterable by cmdlet name. Returns the snooze duration, whether it runs until the item resolves, whether the item stays visible on the dashboard, and who set it.", "parameters": [ { "name": "CmdletName", @@ -58449,8 +60338,11 @@ "IsExpired": { "x-cipp-field-source": "backend" }, - "PartitionKey": { + "KeepVisible": { "type": "string", + "x-cipp-field-source": "storage,backend,frontend" + }, + "PartitionKey": { "x-cipp-field-source": "storage,backend" }, "RemainingDays": { @@ -58489,6 +60381,10 @@ "Timestamp": { "type": "string", "x-cipp-field-source": "storage" + }, + "UntilResolved": { + "type": "string", + "x-cipp-field-source": "storage,backend,frontend" } } } @@ -58619,6 +60515,9 @@ "BulkSpamAction": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -58652,11 +60551,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -58892,7 +60795,7 @@ "bearerAuth": [] } ], - "x-cipp-role": "Tenant.BestPracticeAnalyser.Read" + "x-cipp-role": "Tenant.Standards.Read" } }, "/api/ListStandardsCurrentState": { @@ -59018,54 +60921,31 @@ "type": "array", "items": { "type": "object", - "description": "Derived from the fields written into the storage table it reads, and the fields the endpoint selects onto each record, and the columns the CIPP UI renders. 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, and the columns the CIPP UI renders. The response may carry more; these are the ones known to exist.", "properties": { "displayName": { "x-cipp-field-source": "backend" }, - "ETag": { - "type": "string", - "x-cipp-field-source": "storage" - }, "excludedTenants": { "x-cipp-field-source": "frontend" }, "GUID": { - "x-cipp-field-source": "storage,backend" - }, - "JSON": { - "x-cipp-field-source": "storage" + "x-cipp-field-source": "backend" }, "name": { "x-cipp-field-source": "backend" }, - "Package": { - "x-cipp-field-source": "storage" - }, - "PartitionKey": { - "x-cipp-field-source": "storage" - }, - "Permissions": { - "type": "string", - "x-cipp-field-source": "storage" - }, - "RowKey": { - "x-cipp-field-source": "storage" - }, "runManually": { "x-cipp-field-source": "frontend" }, - "SHA": { - "x-cipp-field-source": "storage" - }, - "Source": { - "x-cipp-field-source": "storage" + "source": { + "x-cipp-field-source": "frontend" }, "standards": { "x-cipp-field-source": "frontend" }, "templateName": { - "x-cipp-field-source": "storage,frontend" + "x-cipp-field-source": "frontend" }, "templates": { "x-cipp-field-source": "backend" @@ -59073,18 +60953,14 @@ "tenantFilter": { "x-cipp-field-source": "frontend" }, - "Timestamp": { - "type": "string", - "x-cipp-field-source": "storage" - }, "type": { "x-cipp-field-source": "frontend" }, "updatedAt": { "x-cipp-field-source": "frontend" }, - "UpdatedBy": { - "x-cipp-field-source": "storage,frontend" + "updatedBy": { + "x-cipp-field-source": "frontend" } } } @@ -59997,6 +61873,9 @@ "compliant": { "x-cipp-field-source": "backend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "currentValue": { "x-cipp-field-source": "backend" }, @@ -60045,11 +61924,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "standardId": { "x-cipp-field-source": "backend" }, @@ -60215,6 +62098,9 @@ "type": "object", "description": "Derived from the fields written into the storage table it reads, and the columns the CIPP UI renders. Fields taken from the storage writers may be omitted by this endpoint, and the response may carry computed fields not listed here.", "properties": { + "ContentHash": { + "x-cipp-field-source": "storage" + }, "entries": { "x-cipp-field-source": "frontend" }, @@ -60257,11 +62143,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage,frontend" }, @@ -60571,6 +62461,9 @@ "Status": { "x-cipp-field-source": "storage,frontend" }, + "TenantGroups": { + "x-cipp-field-source": "storage" + }, "Timestamp": { "type": "string", "x-cipp-field-source": "storage,frontend" @@ -60606,7 +62499,7 @@ "tags": [ "Tenant > Administration > Tenant" ], - "description": "Lists all managed tenants accessible to the current user, with support for cache clearing and tenant filtering. This is the primary endpoint for tenant enumeration.", + "description": "Lists all managed tenants accessible to the current user, with support for cache clearing and tenant filtering. This is the primary endpoint for tenant enumeration. Pass Search for a fuzzy, case-insensitive substring lookup across displayName, defaultDomainName, initialDomainName and customerId when the exact domain is unknown.", "requestBody": { "required": false, "content": { @@ -60651,6 +62544,15 @@ "type": "string" } }, + { + "name": "Search", + "in": "query", + "description": "Fuzzy tenant lookup: case-insensitive substring match over displayName, defaultDomainName, initialDomainName and customerId. Arbitrary verified domains are not indexed by Get-Tenants and cannot be matched here. Supports '*' wildcards. Use this when you don't know the exact domain.", + "required": false, + "schema": { + "type": "string" + } + }, { "name": "TenantFilter", "in": "query", @@ -60662,6 +62564,7 @@ { "name": "TriggerRefresh", "in": "query", + "description": "Re-reads the tenant given in tenantFilter, or queues a refresh of all tenants when omitted. Returns a Results message instead of the tenant list.", "required": false, "schema": { "type": "string" @@ -60783,64 +62686,151 @@ "x-cipp-any-tenant": true } }, - "/api/ListTestResultsTenants": { + "/api/ListTestResultsTenants": { + "get": { + "summary": "Lists CIPP test results for a given test across one, many, or all tenants.", + "operationId": "ListTestResultsTenants", + "tags": [ + "Tenant > Tests" + ], + "description": "Cross-tenant overview of stored test results. Backed by Get-CIPPTestResultsTenants, which\nqueries the shared CippTestResults table server-side and enriches Custom rows with their\ndefinition. Results are filtered to the tenants the calling user is permitted to see via\nTest-CIPPAccess, so an all-tenants read still respects per-user tenant scoping.", + "parameters": [ + { + "name": "category", + "in": "query", + "description": "Restrict to a single category (the framework/area name a test belongs to).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "countsOnly", + "in": "query", + "description": "'true' to return only the aggregate counts with no rows. Implies includeCounts.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "includeCounts", + "in": "query", + "description": "'true' to also return aggregate counts (per status, high-risk failures, distinct tenants).", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "risk", + "in": "query", + "description": "Restrict to a single risk level: High, Medium or Low.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "rowStatus", + "in": "query", + "description": "Return rows only for these statuses, while still counting every status the filters match.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "status", + "in": "query", + "description": "Narrow the scan to these statuses: Passed, Failed, Investigate, Skipped, Informational and Unlicensed.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "summaryOnly", + "in": "query", + "description": "'true' to project away the large ResultMarkdown/ResultDataJson blobs for a lighter read.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "tenantFilter", + "in": "query", + "description": "One or more tenant domains to report on. Omit, or pass 'AllTenants', to query every tenant the caller may see. Accepts a string, a comma-delimited string, or an array.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "testId", + "in": "query", + "description": "One or more test IDs (the result row's RowKey), e.g. 'CustomScript-'.", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "testType", + "in": "query", + "description": "Restrict to a single test type: Identity, Devices or Custom.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "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." + } + } + } + } + }, + "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" + } + }, + "security": [ + { + "bearerAuth": [] + } + ], + "x-cipp-role": "Tenant.Reports.Read", + "x-cipp-any-tenant": true + } + }, + "/api/ListTests": { "get": { - "summary": "Lists CIPP test results for a given test across one, many, or all tenants.", - "operationId": "ListTestResultsTenants", + "summary": "Lists tests for a tenant, optionally filtered by report ID", + "operationId": "ListTests", "tags": [ "Tenant > Tests" ], - "description": "Cross-tenant overview of stored test results. Backed by Get-CIPPTestResultsTenants, which\nqueries the shared CippTestResults table server-side and enriches Custom rows with their\ndefinition. Results are filtered to the tenants the calling user is permitted to see via\nTest-CIPPAccess, so an all-tenants read still respects per-user tenant scoping.", "parameters": [ { - "name": "category", - "in": "query", - "description": "Restrict to a single category (the framework/area name a test belongs to).", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "countsOnly", - "in": "query", - "description": "'true' to return only the aggregate counts with no rows. Implies includeCounts.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "includeCounts", - "in": "query", - "description": "'true' to also return aggregate counts (per status, high-risk failures, distinct tenants).", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "risk", - "in": "query", - "description": "Restrict to a single risk level: High, Medium or Low.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "rowStatus", - "in": "query", - "description": "Return rows only for these statuses, while still counting every status the filters match.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "status", + "name": "reportId", "in": "query", - "description": "Narrow the scan to these statuses: Passed, Failed, Investigate, Skipped, Informational.", "required": false, "schema": { "type": "string" @@ -60849,88 +62839,10 @@ { "name": "summaryOnly", "in": "query", - "description": "'true' to project away the large ResultMarkdown/ResultDataJson blobs for a lighter read.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "tenantFilter", - "in": "query", - "description": "One or more tenant domains to report on. Omit, or pass 'AllTenants', to query every tenant the caller may see. Accepts a string, a comma-delimited string, or an array.", - "required": false, - "schema": { - "type": "string" - } - }, - { - "name": "testId", - "in": "query", - "description": "One or more test IDs (the result row's RowKey), e.g. 'CustomScript-'.", + "description": "When true, return only the aggregated TestCounts (per-type pass/fail/etc totals) and skip the per-result markdown/metadata enrichment and the SecureScore/MFAState/License DB reads.", "required": false, "schema": { - "type": "string" - } - }, - { - "name": "testType", - "in": "query", - "description": "Restrict to a single test type: Identity, Devices or Custom.", - "required": false, - "schema": { - "type": "string" - } - } - ], - "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." - } - } - } - } - }, - "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" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Tenant.Reports.Read", - "x-cipp-any-tenant": true - } - }, - "/api/ListTests": { - "get": { - "summary": "Lists tests for a tenant, optionally filtered by report ID", - "operationId": "ListTests", - "tags": [ - "Tenant > Tests" - ], - "parameters": [ - { - "name": "reportId", - "in": "query", - "required": false, - "schema": { - "type": "string" + "type": "boolean" } }, { @@ -60946,7 +62858,15 @@ "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." + "description": "Derived from the fields the endpoint selects onto each record. The response may carry more; these are the ones known to exist.", + "properties": { + "TenantFilter": { + "x-cipp-field-source": "backend" + }, + "TestCounts": { + "x-cipp-field-source": "backend" + } + } } } } @@ -61110,6 +63030,9 @@ "comments": { "x-cipp-field-source": "frontend" }, + "ContentHash": { + "x-cipp-field-source": "storage" + }, "ETag": { "type": "string", "x-cipp-field-source": "storage" @@ -61137,11 +63060,15 @@ "x-cipp-field-source": "storage" }, "SHA": { + "type": "string", "x-cipp-field-source": "storage" }, "Source": { "x-cipp-field-source": "storage" }, + "SourcePath": { + "x-cipp-field-source": "storage" + }, "TemplateName": { "x-cipp-field-source": "storage" }, @@ -61174,57 +63101,6 @@ "x-cipp-any-tenant": true } }, - "/api/ListUserConditionalAccessPolicies": { - "get": { - "summary": "ListUserConditionalAccessPolicies", - "operationId": "ListUserConditionalAccessPolicies", - "tags": [ - "Identity > Administration > Users" - ], - "description": "Lists Conditional Access policies that apply to a specific user in a tenant.", - "parameters": [ - { - "$ref": "#/components/parameters/tenantFilter" - }, - { - "name": "UserID", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": { - "type": "object", - "description": "Not described statically: this endpoint returns the upstream response as-is, so its fields are determined by the upstream API rather than by CIPP. Call the endpoint to see the actual shape, or add a response schema in backend/Config/openapi-overrides." - } - } - } - } - }, - "401": { - "description": "Unauthorized - invalid or missing bearer token" - }, - "403": { - "description": "Forbidden - caller lacks the required RBAC role" - } - }, - "security": [ - { - "bearerAuth": [] - } - ], - "x-cipp-role": "Identity.User.Read" - } - }, "/api/ListUserCounts": { "get": { "summary": "ListUserCounts", @@ -63725,6 +65601,9 @@ "PartitionKey": { "x-cipp-field-source": "storage" }, + "PSAConsolidationKey": { + "x-cipp-field-source": "storage" + }, "PsaTicketId": { "x-cipp-field-source": "storage" }, @@ -64011,6 +65890,16 @@ } } }, + "parameters": [ + { + "name": "client", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], "responses": { "200": { "description": "Success", @@ -64869,67 +66758,6 @@ "x-cipp-role": "Tenant.Baselines.ReadWrite" } }, - "/api/RemoveBPATemplate": { - "post": { - "summary": "RemoveBPATemplate", - "operationId": "RemoveBPATemplate", - "tags": [ - "Tenant > Standards" - ], - "requestBody": { - "required": false, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "TemplateName": { - "type": "string" - } - } - } - } - } - }, - "parameters": [ - { - "name": "TemplateName", - "in": "query", - "required": false, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Success", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/StandardResults" - } - } - } - }, - "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": "Tenant.Standards.ReadWrite" - } - }, "/api/RemoveCAPolicy": { "post": { "summary": "RemoveCAPolicy", @@ -67938,6 +69766,9 @@ "PartitionKey": { "x-cipp-field-source": "storage" }, + "PSAConsolidationKey": { + "x-cipp-field-source": "storage" + }, "PsaTicketId": { "x-cipp-field-source": "storage" }, diff --git a/Config/standards.json b/Config/standards.json index 4653ad33f0aaa..196396f5fa630 100644 --- a/Config/standards.json +++ b/Config/standards.json @@ -1555,7 +1555,7 @@ "name": "standards.NudgeMFA.targetedAuthenticationMethod", "options": [ { "label": "Microsoft Authenticator", "value": "microsoftAuthenticator" }, - { "label": "Passkey (FIDO2)", "value": "fido2" } + { "label": "Passkey (FIDO2)", "value": "FIDO2" } ], "condition": { "field": "standards.NudgeMFA.state", @@ -2173,6 +2173,51 @@ "powershellEquivalent": "Update-MgUser -UserId user@domain.com -BodyParameter @{preferredLanguage='en-US'}", "recommendedBy": [] }, + { + "name": "standards.UsageLocation", + "cat": "Entra (AAD) Standards", + "tag": [], + "helpText": "Sets the Entra usage location (the two-letter country code that licensing and security tools such as Huntress ITDR depend on) on every member account. Optionally limit the sweep to members of named groups, skip members of named groups (users legitimately based in another country), or only fill in accounts that have no usage location yet.", + "docsDescription": "Sets the usage location on every member account; guest accounts are ignored. Group names are resolved per tenant by display name and expanded to their transitive user members: include groups restrict the sweep to those members, exclude groups remove them from it. A configured group that does not exist in a tenant, or a failed membership lookup, skips the run rather than sweeping the wrong accounts. With 'only set when blank' enabled, accounts that already carry any usage location are left as they are. Directory-synced accounts are included: usage location is cloud-managed and stays writable for them unless a custom sync rule maps it from on-premises.", + "executiveText": "Ensures every employee account records the country it is used from, which Microsoft requires before licences can be assigned and which security monitoring uses to recognise sign-ins from unexpected locations. Staff based in other countries are exempted through a group.", + "addedComponent": [ + { + "type": "CountryCodeSelect", + "required": true, + "name": "standards.UsageLocation.usageLocation", + "label": "Usage location" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "name": "standards.UsageLocation.includeGroups", + "label": "Only apply to members of these groups (display names; blank = all member accounts)" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "name": "standards.UsageLocation.excludeGroups", + "label": "Skip members of these groups (display names; users legitimately located elsewhere)" + }, + { + "type": "switch", + "name": "standards.UsageLocation.onlyWhenBlank", + "label": "Only set accounts that have no usage location (never overwrite an existing value)", + "required": false + } + ], + "label": "Usage location for all users", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-09-16", + "powershellEquivalent": "Update-MgUser -UserId user@domain.com -UsageLocation 'US'", + "recommendedBy": [], + "requiredCapabilities": [] + }, { "name": "standards.AppManagementPolicy", "cat": "Entra (AAD) Standards", @@ -6180,6 +6225,39 @@ "recommendedBy": ["CIS"], "requiredCapabilities": ["MCOSTANDARD", "MCOEV", "MCOIMP", "TEAMS1", "Teams_Room_Standard"] }, + { + "name": "standards.PlannerBlockTaskDelete", + "cat": "Teams Standards", + "tag": [], + "helpText": "Sets the Planner user policy that blocks each in-scope member from deleting Planner tasks they did not create. Applies across all basic Planner plans for that user (not a single board). Only enabled members with an enabled Microsoft Planner (PROJECTWORKMANAGEMENT) service plan are touched. Optionally limit the sweep to members of named groups or skip members of named groups. The remediate wash is limited to once per 24 hours per tenant because there is no list API and every user needs its own write. Alert and report still run every time. May also prevent those users from deleting plans. Requires CIPP-SAM consent for ProjectWorkManagement OrgSettings-Planner permissions.", + "docsDescription": "Uses the Planner tenant admin UserPolicy API (tasks.office.com) to set blockDeleteTasksNotCreatedBySelf to true for each in-scope enabled member account that has an enabled PROJECTWORKMANAGEMENT (Microsoft Planner) service plan in the user cache. Guest accounts, disabled accounts, and members without that plan are ignored. Group names are resolved per tenant by display name and expanded to their transitive user members: include groups restrict the sweep to those members, exclude groups remove them from it. A configured group that does not exist in a tenant, or a failed membership lookup, skips the run rather than sweeping the wrong accounts. When no include group is named, every Planner-licensed enabled member account is in scope. Remediate is an idempotent PUT per candidate with no preflight GET; a 24-hour rerun guard (Test-CIPPRerun) skips repeating that write sweep for the same settings, while alert and report still GET live state each run. The policy is per user and applies to all basic plans that user can access; it is not board-scoped. Known side effect: the same policy can also block plan deletion. Requires application permissions OrgSettings-Planner.ReadWrite.All on ProjectWorkManagement (tasks.office.com), consented via CIPP-SAM repair and CPV refresh.", + "executiveText": "Stops employees from deleting Planner tasks they did not create, reducing accidental loss on boards used as shared work queues. Can be limited to a department security group so only those staff are locked down.", + "addedComponent": [ + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "name": "standards.PlannerBlockTaskDelete.includeGroups", + "label": "Only apply to members of these groups (display names; blank = all Planner-licensed enabled members)" + }, + { + "type": "autoComplete", + "multiple": true, + "creatable": true, + "required": false, + "name": "standards.PlannerBlockTaskDelete.excludeGroups", + "label": "Skip members of these groups (display names)" + } + ], + "label": "Block Planner users from deleting tasks they did not create", + "impact": "Medium Impact", + "impactColour": "warning", + "addedDate": "2026-09-16", + "powershellEquivalent": "Set-PlannerUserPolicy -UserAadIdOrPrincipalName user@domain.com -BlockDeleteTasksNotCreatedBySelf $true", + "recommendedBy": [], + "requiredCapabilities": ["PROJECTWORKMANAGEMENT"] + }, { "name": "standards.TeamsChatProtection", "cat": "Teams Standards", @@ -6804,8 +6882,8 @@ "name": "standards.DevicePrepProfile.AccountType", "label": "Account Type", "options": [ - { "label": "Standard user", "value": "0" }, - { "label": "Administrator", "value": "1" } + { "label": "Standard user", "value": "1" }, + { "label": "Administrator", "value": "0" } ] }, { @@ -8123,15 +8201,128 @@ "name": "standards.MessageEncryption", "cat": "Exchange Standards", "tag": [], - "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online, and turns on simplified client access so the Encrypt button appears in Outlook on the web and the new Outlook. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)", - "docsDescription": "Sets AzureRMSLicensingEnabled to true, the prerequisite for Microsoft Purview Message Encryption, and SimplifiedClientAccessEnabled to true so the Encrypt button appears when composing mail in Outlook on the web and the new Outlook. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.", + "helpText": "Enables Microsoft Purview Message Encryption by turning on Azure RMS licensing for Exchange Online, and turns on simplified client access so the Encrypt button appears in Outlook on the web and the new Outlook. Skipped with a warning when the tenant still points at an on-premises AD RMS cluster, because AD RMS has to be migrated to Azure RMS first. The optional settings below default to \"Do not change\", which leaves the tenant's current value alone and out of the drift check. This standard only turns the feature on: branding, one-time passcodes, and social ID sign-in for encrypted messages are configured in the [Configure Encrypted Message Branding (OME)](https://standards.cipp.app/standards/omebranding) standard. [Read more](https://learn.microsoft.com/en-us/purview/set-up-new-message-encryption-capabilities)", + "docsDescription": "Sets AzureRMSLicensingEnabled to true, the prerequisite for Microsoft Purview Message Encryption, and SimplifiedClientAccessEnabled to true so the Encrypt button appears when composing mail in Outlook on the web and the new Outlook. Optionally also enforces EnablePdfEncryption, DecryptAttachmentForEncryptOnly, SimplifiedClientAccessDoNotForwardDisabled, SimplifiedClientAccessEncryptOnlyDisabled and TransportDecryptionSetting; each of these is left untouched unless the template picks a value. Reports the IRM licensing state per tenant, including the licensing location, so you can see at a glance which tenants have message encryption available. Remediation is deliberately skipped for tenants with an on-premises AD RMS licensing location, as Purview Message Encryption is not compatible with AD RMS and those tenants need to be migrated to Azure RMS first.", "executiveText": "Turns on the built-in encryption that lets staff send protected email to anyone, including recipients outside the organization. Uses licensing the organization already owns, removing the need for a separate secure-email product.", - "addedComponent": [], + "addedComponent": [ + { + "type": "radio", + "name": "standards.MessageEncryption.EnablePdfEncryption", + "label": "Encrypt PDF attachments", + "required": false, + "defaultValue": "donotchange", + "helperText": "Applies Encrypt-only and Do Not Forward protection to PDF attachments as well as the message body.", + "options": [ + { + "value": "donotchange", + "label": "Do not change" + }, + { + "value": "true", + "label": "Enable" + }, + { + "value": "false", + "label": "Disable" + } + ] + }, + { + "type": "radio", + "name": "standards.MessageEncryption.DecryptAttachmentForEncryptOnly", + "label": "Let recipients save Encrypt-only attachments unprotected", + "required": false, + "defaultValue": "donotchange", + "helperText": "When enabled, attachments on Encrypt-only mail are decrypted when the recipient downloads them.", + "options": [ + { + "value": "donotchange", + "label": "Do not change" + }, + { + "value": "true", + "label": "Enable" + }, + { + "value": "false", + "label": "Disable" + } + ] + }, + { + "type": "radio", + "name": "standards.MessageEncryption.SimplifiedClientAccessDoNotForwardDisabled", + "label": "Do Not Forward option in the Outlook Encrypt menu", + "required": false, + "defaultValue": "donotchange", + "options": [ + { + "value": "donotchange", + "label": "Do not change" + }, + { + "value": "true", + "label": "Hide" + }, + { + "value": "false", + "label": "Show" + } + ] + }, + { + "type": "radio", + "name": "standards.MessageEncryption.SimplifiedClientAccessEncryptOnlyDisabled", + "label": "Encrypt-only option in the Outlook Encrypt menu", + "required": false, + "defaultValue": "donotchange", + "options": [ + { + "value": "donotchange", + "label": "Do not change" + }, + { + "value": "true", + "label": "Hide" + }, + { + "value": "false", + "label": "Show" + } + ] + }, + { + "type": "radio", + "name": "standards.MessageEncryption.TransportDecryptionSetting", + "label": "Transport decryption", + "required": false, + "defaultValue": "donotchange", + "helperText": "Disabled leaves protected mail encrypted in transit, Optional decrypts it where possible and delivers either way, Mandatory rejects mail it cannot decrypt.", + "options": [ + { + "value": "donotchange", + "label": "Do not change" + }, + { + "value": "Disabled", + "label": "Disabled" + }, + { + "value": "Optional", + "label": "Optional" + }, + { + "value": "Mandatory", + "label": "Mandatory" + } + ] + } + ], "label": "Enable Purview Message Encryption", "impact": "Low Impact", "impactColour": "info", "addedDate": "2026-08-04", - "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true -SimplifiedClientAccessEnabled $true", + "powershellEquivalent": "Set-IRMConfiguration -AzureRMSLicensingEnabled $true -SimplifiedClientAccessEnabled $true [-EnablePdfEncryption ...] [-DecryptAttachmentForEncryptOnly ...] [-SimplifiedClientAccessDoNotForwardDisabled ...] [-SimplifiedClientAccessEncryptOnlyDisabled ...] [-TransportDecryptionSetting ...]", "recommendedBy": [], "requiredCapabilities": [ "EXCHANGE_S_STANDARD", diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 index 2fda2e5351d42..19ddc76f088db 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BEC/Push-BECRun.ps1 @@ -1,24 +1,114 @@ -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. Collects audit-log changes, sign-ins, + rules, safelists, sharing, sent mail, apps, MFA, devices, 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 the BecResults table + (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 + $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 (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 = [string]$UserName + $Progress = @{ Current = $null } + try { + # (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' -TenantFilter $TenantFilter + 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 { - $startDate = (Get-Date).ToUniversalTime().AddDays(-7) + $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,73 +117,82 @@ # renders a locale string neither understands $SignInDate = { if ($_.createdDateTime) { ([datetime]$_.createdDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } } + # Licence preflight, up front and all at once: a check the tenant cannot support is skipped with + # its reason instead of run only to fail. Get-CIPPTenantCapabilities is CIPP's shared (cached) + # service-plan read and the plan names are the ones Test-CIPPStandardLicense's presets use. If + # the read itself fails every check runs and the error classifier (Get-CIPPBecErrorInfo, via + # $Mark) is the safety net - never skip on a failed preflight. + $Capabilities = $null + try { + $Capabilities = Get-CIPPTenantCapabilities -TenantFilter $TenantFilter + } catch { + Write-LogMessage -API 'BECRun' -message "BEC preflight could not read tenant plans for $($TenantFilter): $((Get-NormalizedError -message $_.Exception.Message))" -tenant $TenantFilter -sev Info + } + $HasPlan = { param([string[]]$Plans) (-not $Capabilities) -or [bool]@($Plans | Where-Object { $Capabilities.$_ -eq $true }).Count } + $HasEntraP2 = & $HasPlan 'AAD_PREMIUM_P2' + $HasDefenderP2 = & $HasPlan 'THREAT_INTELLIGENCE', 'THREAT_INTELLIGENCE_GOV' + $HasIntune = & $HasPlan 'INTUNE_A', 'MDM_Services', 'EMS', 'SCCM', 'MICROSOFTINTUNEPLAN1' + $Skip = { param($Requirement) New-CIPPBecCollectorResult -Data @() -Skipped $true -Requirement $Requirement } + + & $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 } - Write-Information 'Getting last sign-in' - try { - $URI = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=(userId eq '$SuspectUser')&`$top=1&`$orderby=createdDateTime desc" - $LastSignIn = New-GraphGetRequest -uri $URI -tenantid $TenantFilter -noPagination $true -verbose | Select-Object @{ Name = 'CreatedDateTime'; Expression = $SignInDate }, - id, - @{ Name = 'AppDisplayName'; Expression = { $_.resourceDisplayName } }, - @{ Name = 'Status'; Expression = $SignInStatus }, - @{ Name = 'IPAddress'; Expression = { $_.ipAddress } }, - @{ Name = 'Country'; Expression = { $_.location.countryOrRegion } }, - @{ Name = 'City'; Expression = { $_.location.city } } - } catch { - $LastSignIn = [PSCustomObject]@{ - AppDisplayName = 'Unknown - could not retrieve information. No access to sign-in logs' - CreatedDateTime = 'Unknown' - Id = '0' - Status = 'Could not retrieve additional details' - } - } + & $Phase 'SignIns' 'Reading sign-ins and mobile devices' Write-Information 'Getting suspect user sign-ins' $SuspectUserSignInsError = $null try { - $URI = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=(userId eq '$SuspectUser')&`$top=50&`$orderby=createdDateTime desc" - $SuspectUserSignIns = @(New-GraphGetRequest -uri $URI -tenantid $TenantFilter -noPagination $true | Select-Object @{ Name = 'CreatedDateTime'; Expression = $SignInDate }, + # Every interactive sign-in in the window, paged to the end: the score's foreign sign-in + # signals count these, so a newest-N cut would hide the first foreign access. + $URI = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=(userId eq '$SuspectUser') and createdDateTime ge $($startDate.ToString('yyyy-MM-ddTHH:mm:ssZ'))&`$top=999&`$orderby=createdDateTime desc" + $SuspectUserSignIns = @(New-GraphGetRequest -uri $URI -tenantid $TenantFilter | Select-Object @{ Name = 'CreatedDateTime'; Expression = $SignInDate }, id, @{ Name = 'AppDisplayName'; Expression = { $_.resourceDisplayName } }, @{ Name = 'ClientAppUsed'; Expression = { $_.clientAppUsed } }, @{ Name = 'Status'; Expression = $SignInStatus }, @{ Name = 'IPAddress'; Expression = { $_.ipAddress } }, @{ Name = 'Country'; Expression = { $_.location.countryOrRegion } }, - @{ Name = 'City'; Expression = { $_.location.city } }) + @{ Name = 'City'; Expression = { $_.location.city } }, + # what the IP verdicts weigh: the network, Entra's own risk call, the client and device, + # and the session/token ids that tie audited actions back to this sign-in + @{ Name = 'ASN'; Expression = { $_.autonomousSystemNumber } }, + @{ Name = 'RiskLevelDuringSignIn'; Expression = { $_.riskLevelDuringSignIn } }, + @{ Name = 'RiskEventTypes'; Expression = { @($_.riskEventTypes_v2) } }, + @{ Name = 'UserAgent'; Expression = { $_.userAgent } }, + @{ Name = 'DeviceCompliant'; Expression = { $_.deviceDetail.isCompliant } }, + @{ Name = 'DeviceManaged'; Expression = { $_.deviceDetail.isManaged } }, + @{ Name = 'DeviceTrustType'; Expression = { $_.deviceDetail.trustType } }, + @{ Name = 'OperatingSystem'; Expression = { $_.deviceDetail.operatingSystem } }, + @{ Name = 'Browser'; Expression = { $_.deviceDetail.browser } }, + @{ Name = 'SessionId'; Expression = { $_.sessionId } }, + @{ Name = 'UniqueTokenId'; Expression = { $_.uniqueTokenIdentifier } }, + # the CIPP application's own sign-ins (its service account) are not the user's or an attacker's + @{ Name = 'AppId'; Expression = { $_.appId } }) + & $Mark 'SignIns' ([pscustomobject]@{ Complete = $true; Cap = $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 +200,91 @@ $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 } + $TargetsSuspect = ($TargetCandidates -like "*$UserName*" -or ($UserLocalPart -and $TargetCandidates -like "*$UserLocalPart*")) + [pscustomobject]@{ + Operation = $AD.Operation + UserKey = $AD.UserKey + UserId = $AD.UserId + ObjectId = $objectId + Permissions = $perms + Trustee = [string]$Trustee + Date = $AD.CreationTime + ClientIP = ConvertTo-CIPPBecHostAddress -Address ($AD.ClientIP ?? $AD.ClientIPAddress) + TargetsSuspect = $TargetsSuspect + # the full audit record, kept for the rows about this mailbox (the search is tenant-wide) + AuditData = if ($TargetsSuspect) { $AD } else { $null } + } + }) } 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 = ConvertTo-CIPPBecHostAddress -Address ($_.ClientIP ?? $_.ClientIPAddress) + AuditData = $_ } - } + }) } } 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 +293,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 +354,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 = ConvertTo-CIPPBecHostAddress -Address ($_.ClientIP ?? $_.ClientIPAddress) + AuditData = $_ + # 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 = ConvertTo-CIPPBecHostAddress -Address ($_.ClientIP ?? $_.ClientIPAddress) + AuditData = $_ + } + }) } } 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' 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 + $SentMessagesRaw = @($SentTrace.Rows) + $SentMessages = @($SentMessagesRaw | Select-Object MessageTraceId, Status, Subject, RecipientAddress, @{ Name = 'Received'; Expression = { ([datetime]$_.Received).ToString('u') } }, FromIP) + & $Mark 'SentMessages' ([pscustomobject]@{ Complete = $SentTrace.Complete; Cap = $SentTrace.Cap; 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 +440,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 +460,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 +468,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,39 +496,18 @@ Write-LogMessage -API 'BECRun' -message "Failed to analyze sent message patterns for $($UserName): $($_.Exception.Message)" -tenant $TenantFilter -sev Warning } - 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 }, - id, - @{ Name = 'AppDisplayName'; Expression = { $_.resourceDisplayName } }, - @{ Name = 'Status'; Expression = $SignInStatus }, - @{ Name = 'IPAddress'; Expression = { $_.ipAddress } }, - @{ Name = 'Country'; Expression = { $_.location.countryOrRegion } }, - @{ Name = 'City'; Expression = { $_.location.city } }, UserPrincipalName, UserDisplayName - } catch { - $TenantLastSignIns = @( - [PSCustomObject]@{ - AppDisplayName = 'Unknown - could not retrieve information. No access to sign-in logs' - CreatedDateTime = 'Unknown' - Id = '0' - Status = 'Could not retrieve additional details' - Exception = $_.Exception.Message - } - ) - } - - # Known-malicious application catalog shipped with CIPP; matched on appId below. - $MaliciousAppsCatalog = try { - @((Get-Content -Path (Join-Path $env:CIPPRootPath 'Config\MaliciousApps.json') -ErrorAction Stop | ConvertFrom-Json).applications) - } catch { - Write-Information "Could not load MaliciousApps.json: $($_.Exception.Message)" - @() - } + & $Phase 'Tenant' 'Reading tenant users, MFA methods and applications' + # The rogue-application catalog (CIPP MaliciousApps.json + the Huntress feed) keyed by lowercase + # appId; shared with the consent collector so every section matches the same list. + $RogueAppFeed = Get-CIPPBecRogueAppFeed + $RogueApps = $RogueAppFeed.Apps + $CatalogAppIds = @($RogueApps.Keys) + $RogueMatch = { param($AppId) $Key = ([string]$AppId).ToLowerInvariant(); if ($Key -and $RogueApps.ContainsKey($Key)) { $RogueApps[$Key] } else { $null } } $Requests = @( @{ id = 'Users' - url = "users?`$select=id,displayName,userPrincipalName,createdDateTime,lastPasswordChangeDateTime" + url = "users?`$select=id,displayName,userPrincipalName,userType,createdDateTime,lastPasswordChangeDateTime" method = 'GET' } @{ @@ -389,10 +520,12 @@ url = "servicePrincipals?`$select=displayName,createdDateTime,appId,appDisplayName,publisher&`$filter=createdDateTime ge $($startDate.ToString('yyyy-MM-ddTHH:mm:ssZ'))" method = 'GET' } - @{ - id = 'IntuneDevices' - url = "users/$($SuspectUser)/managedDevices" - method = 'GET' + if ($HasIntune) { + @{ + id = 'IntuneDevices' + url = "users/$($SuspectUser)/managedDevices" + method = 'GET' + } } @{ id = 'SuspectUser' @@ -402,18 +535,22 @@ ) # Look for catalog apps present in the tenant regardless of age, chunked to keep each # 'in' filter within Graph's operand limit. - $CatalogAppIds = @($MaliciousAppsCatalog.appId | Where-Object { $_ }) - for ($i = 0; $i -lt $CatalogAppIds.Count; $i += 15) { - $Chunk = $CatalogAppIds[$i..([Math]::Min($i + 14, $CatalogAppIds.Count - 1))] - $Requests += @{ - id = "MaliciousSPs$i" - url = "servicePrincipals?`$select=displayName,appId,accountEnabled,createdDateTime&`$filter=appId in ('$($Chunk -join "','")')" - method = 'GET' - } - } + $Requests = @($Requests) + @(for ($i = 0; $i -lt $CatalogAppIds.Count; $i += 15) { + $Chunk = $CatalogAppIds[$i..([Math]::Min($i + 14, $CatalogAppIds.Count - 1))] + @{ + id = "MaliciousSPs$i" + url = "servicePrincipals?`$select=displayName,appId,accountEnabled,createdDateTime&`$filter=appId in ('$($Chunk -join "','")')" + method = 'GET' + } + }) 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 }) ?? @() @@ -426,9 +563,9 @@ # Flag service principals added during the window that match the malicious catalog $NewSPs = @(foreach ($SP in @($NewSPs)) { - $CatalogEntry = $MaliciousAppsCatalog | Where-Object { $_.appId -eq $SP.appId } | Select-Object -First 1 + $CatalogEntry = & $RogueMatch $SP.appId $Match = if ($CatalogEntry) { - [PSCustomObject]@{ Name = $CatalogEntry.name; Categories = @($CatalogEntry.categories); Description = $CatalogEntry.description } + [PSCustomObject]@{ Name = $CatalogEntry.Name; Categories = @($CatalogEntry.Categories); Description = $CatalogEntry.Description; Source = $CatalogEntry.Source } } else { $null } $SP | Select-Object *, @{ Name = 'MaliciousMatch'; Expression = { $Match } } }) @@ -437,78 +574,303 @@ # password reset, so an old grant matters as much as a new one. $MaliciousSPResults = @($GraphResults | Where-Object { $_.id -like 'MaliciousSPs*' -and [int]$_.status -lt 400 } | ForEach-Object { $_.body.value } | Where-Object { $_ }) $MaliciousSPs = @(foreach ($SP in $MaliciousSPResults) { - $CatalogEntry = $MaliciousAppsCatalog | Where-Object { $_.appId -eq $SP.appId } | Select-Object -First 1 + $CatalogEntry = & $RogueMatch $SP.appId [PSCustomObject]@{ displayName = $SP.displayName appId = $SP.appId accountEnabled = $SP.accountEnabled createdDateTime = $SP.createdDateTime - CatalogName = $CatalogEntry.name - Categories = @($CatalogEntry.categories) - Description = $CatalogEntry.description + CatalogName = $CatalogEntry.Name + Categories = @($CatalogEntry.Categories) + Description = $CatalogEntry.Description + Source = $CatalogEntry.Source } }) - # 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 + # Intune managed devices for the suspect user - surface Graph failures instead of a silent empty list $IntuneDevicesError = $null $IntuneDevices = @() - if (-not $IntuneResponse) { - $IntuneDevicesError = 'Intune device query did not return a response' - } elseif ([int]$IntuneResponse.status -ge 400) { - # Graph proxies this call to Intune's DeviceFE service, which returns its own JSON - # error blob as the Graph error message. Unwrap it so the report shows a readable - # sentence instead of raw JSON, keeping the Activity ID for Microsoft support cases. - $RawIntuneError = $IntuneResponse.body.error.message - $IntuneDevicesError = $RawIntuneError - if ($RawIntuneError -match '^\s*\{') { - try { - $ParsedIntuneError = $RawIntuneError | ConvertFrom-Json -ErrorAction Stop - if (-not [string]::IsNullOrWhiteSpace($ParsedIntuneError.Message)) { - $IntuneDevicesError = $ParsedIntuneError.Message + if (-not $HasIntune) { + & $Mark 'IntuneDevices' (& $Skip 'requires an Intune licence') + } else { + $IntuneResponse = $GraphResults | Where-Object { $_.id -eq 'IntuneDevices' } | Select-Object -First 1 + if (-not $IntuneResponse) { + $IntuneDevicesError = 'Intune device query did not return a response' + } elseif ([int]$IntuneResponse.status -ge 400) { + # Graph proxies this call to Intune's DeviceFE service, which returns its own JSON + # error blob as the Graph error message. Unwrap it so the report shows a readable + # sentence instead of raw JSON, keeping the Activity ID for Microsoft support cases. + $RawIntuneError = $IntuneResponse.body.error.message + $IntuneDevicesError = $RawIntuneError + if ($RawIntuneError -match '^\s*\{') { + try { + $ParsedIntuneError = $RawIntuneError | ConvertFrom-Json -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($ParsedIntuneError.Message)) { + $IntuneDevicesError = $ParsedIntuneError.Message + } + } catch { Write-Verbose 'Intune error body is not JSON; keeping the raw message' } + } + if ($IntuneDevicesError -like 'An error has occurred*') { + $ActivityId = [regex]::Match($IntuneDevicesError, 'Activity ID: ([0-9a-fA-F-]{36})').Groups[1].Value + $IntuneDevicesError = "Intune returned an unexpected error (HTTP $($IntuneResponse.status)). This is a failure inside the Intune service itself and is usually transient. Rerun the check to retry.$(if ($ActivityId) { " Microsoft support reference (Activity ID): $ActivityId" })" + } + if ([string]::IsNullOrWhiteSpace($IntuneDevicesError)) { + $IntuneDevicesError = "Intune device query failed with status $($IntuneResponse.status)" + } + Write-LogMessage -API 'BECRun' -message "Failed to retrieve Intune devices for $($UserName): $IntuneDevicesError" -tenant $TenantFilter -sev Warning + } else { + $IntuneDevicesRaw = $IntuneResponse.body.value ?? @() + $IntuneDevices = @( + foreach ($Device in @($IntuneDevicesRaw)) { + [PSCustomObject]@{ + id = $Device.id + deviceName = $Device.deviceName + operatingSystem = $Device.operatingSystem + osVersion = $Device.osVersion + complianceState = $Device.complianceState + enrolledDateTime = if ($Device.enrolledDateTime) { ([datetime]$Device.enrolledDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + lastSyncDateTime = if ($Device.lastSyncDateTime) { ([datetime]$Device.lastSyncDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + deviceEnrollmentType = $Device.deviceEnrollmentType + manufacturer = $Device.manufacturer + model = $Device.model + serialNumber = $Device.serialNumber + userPrincipalName = $Device.userPrincipalName + managedDeviceOwnerType = $Device.managedDeviceOwnerType + } } - } catch { - # Not valid JSON after all - keep the raw message + ) + } + & $Mark 'IntuneDevices' ([pscustomobject]@{ Complete = (-not $IntuneDevicesError); Cap = $null; Error = $IntuneDevicesError; Count = $IntuneDevices.Count }) + } + + # --------------------------------------------------------------------------------- + # The deeper collectors. Each one degrades to an Error marker - a failed collector + # never fails the run. + # --------------------------------------------------------------------------------- + $MailboxState = $null + $Delegations = @() + $MailboxAddIns = @() + $TransportRuleChanges = @() + $TransportRulesFlagged = @() + $ReceivedMailFindings = @() + $ReceivedMailSummary = $null + $DefenderDetections = @() + $AcceptedDomains = @() + & $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]) } + + $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 + } + } + + Write-Information 'Full scope: mailbox inventory' + $Inventory = & $Collect 'MailboxInventory' { Get-CIPPBecMailboxInventory -TenantFilter $TenantFilter -UserPrincipalName $UserName -Heuristics $Heuristics -AcceptedDomains $AcceptedDomains } + 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 } } - if ($IntuneDevicesError -like 'An error has occurred*') { - $ActivityId = [regex]::Match($IntuneDevicesError, 'Activity ID: ([0-9a-fA-F-]{36})').Groups[1].Value - $IntuneDevicesError = "Intune returned an unexpected error (HTTP $($IntuneResponse.status)). This is a failure inside the Intune service itself - usually transient, or the tenant does not have Intune provisioned. Rerun the check to retry.$(if ($ActivityId) { " Microsoft support reference (Activity ID): $ActivityId" })" + $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] } - if ([string]::IsNullOrWhiteSpace($IntuneDevicesError)) { - $IntuneDevicesError = "Intune device query failed with status $($IntuneResponse.status)" + } 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 -RogueAppFeed $RogueAppFeed } + & $Mark 'UserGrants' $Grants + $UserGrants = @($Grants.Data) + + & $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) + } 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) + } 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 } + & $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 -StartDate $startDate } + & $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 + # the raw records, for the attacker-activity pass (not stored) + $MailRecords = @($Activity.Records | Where-Object { $_ }) + + Write-Information 'Full scope: risk state' + $Risk = if ($HasEntraP2) { & $Collect 'RiskState' { Get-CIPPBecRiskState -TenantFilter $TenantFilter -UserId $SuspectUser -StartDate $startDate } } else { & $Skip 'requires Entra ID P2 (Identity Protection)' } + & $Mark 'RiskState' $Risk + $RiskState = $Risk.Data + + # Who did it: a partner (GDAP) identity, CIPP's own service principal, an application or the + # tenant's own user. Stamped on every actor-bearing row so partner and CIPP actions read as such + # (the case page lists them together) instead of as unknown actors. The score is not changed. + $PartnerUsers = try { Get-CIPPPartnerUserLookup } catch { Write-Information "BEC: partner user lookup unavailable: $($_.Exception.Message)"; @{} } + $StampActor = { + param($Rows, $Property, $TypeProperty, $IdProperty) + foreach ($Row in @($Rows)) { + if (-not $Row) { continue } + $Type = if ($TypeProperty) { [string]$Row.$TypeProperty } else { 'User' } + $AppId = if ($IdProperty -and $Type -eq 'Application') { [string]$Row.$IdProperty } else { $null } + $Who = Resolve-CIPPAuditActor -Actor ([string]$Row.$Property) -ActorType $Type -AppId $AppId -PartnerUserLookup $PartnerUsers + $Row | Add-Member -NotePropertyName 'ActorKind' -NotePropertyValue $Who.Kind -Force + $Row | Add-Member -NotePropertyName 'ActorResolved' -NotePropertyValue $Who.Actor -Force } - Write-LogMessage -API 'BECRun' -message "Failed to retrieve Intune devices for $($UserName): $IntuneDevicesError" -tenant $TenantFilter -sev Warning + } + & $StampActor $RuleChangesLog 'UserKey' + & $StampActor $PermissionsLog 'UserId' + & $StampActor $SafelistChanges 'UserKey' + & $StampActor $SharingChanges 'UserKey' + & $StampActor $TransportRuleChanges 'Actor' + & $StampActor $DirectoryAudits 'InitiatedBy' 'InitiatedByType' 'InitiatedById' + & $StampActor $MailActivity 'Actor' + + # Which addresses are the attacker's: sign-in baseline, CIPP's IP allow/block list and the + # tenant's own lists, geo, and the other accounts on each address. The IP review re-run calls + # the same analysis with the investigator's overrides. + & $Phase 'IPAnalysis' 'Establishing attacker IPs from the sign-in baseline, IP lists and other accounts' + Write-Information 'Full scope: IP analysis' + $IPDraft = [pscustomobject]@{ + SuspectUserSignIns = @($SuspectUserSignIns) + NonInteractiveSignIns = @($NonInteractiveSignIns) + NewRules = @($RulesLog) + InboxRuleChanges = @($RuleChangesLog) + MailboxPermissionChanges = @($PermissionsLog) + SafelistChanges = @($SafelistChanges) + SharingChanges = @($SharingChanges) + TransportRuleChanges = @($TransportRuleChanges) + DirectoryAudits = @($DirectoryAudits) + SentMessages = @($SentMessages) + SentMessageAnalysis = $SentMessageAnalysis + MailActivity = @($MailActivity) + } + # the technician who started the run: their address is theirs, not the user's or the attacker's + $TechnicianIPs = @(if ($Item.RequestedFromIP) { [pscustomobject]@{ IP = [string]$Item.RequestedFromIP; By = [string]$Item.RequestedBy } }) + $IPAnalysis = & $Collect 'IPAnalysis' { Invoke-CIPPBecIPAnalysis -TenantFilter $TenantFilter -UserId $SuspectUser -UserPrincipalName $UserName -Results $IPDraft -Heuristics $Heuristics -WindowStart $startDate -UsageLocation $UsageLocation -Anchor $UserName -SampleColleagues -TechnicianIPs $TechnicianIPs } + if ($IPAnalysis.PSObject.Properties['Verdicts']) { + & $Mark 'SignInBaseline' $IPAnalysis.Baseline + & $Mark 'IPGuidance' $IPAnalysis.Guidance + & $Mark 'IPPeers' $IPAnalysis.PeersResult + & $Mark 'IPVerdicts' ([pscustomobject]@{ Complete = $true; Count = @($IPAnalysis.Verdicts).Count }) + $IPVerdicts = @($IPAnalysis.Verdicts) + $IPBaseline = $IPAnalysis.Baseline.Data + $IPGuidance = @($IPAnalysis.Guidance.Data) + $IPPeers = @($IPAnalysis.Peers.Values) } else { - $IntuneDevicesRaw = $IntuneResponse.body.value ?? @() - $IntuneDevices = @( - foreach ($Device in @($IntuneDevicesRaw)) { - [PSCustomObject]@{ - id = $Device.id - deviceName = $Device.deviceName - operatingSystem = $Device.operatingSystem - osVersion = $Device.osVersion - complianceState = $Device.complianceState - enrolledDateTime = if ($Device.enrolledDateTime) { ([datetime]$Device.enrolledDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } - lastSyncDateTime = if ($Device.lastSyncDateTime) { ([datetime]$Device.lastSyncDateTime).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } - deviceEnrollmentType = $Device.deviceEnrollmentType - manufacturer = $Device.manufacturer - model = $Device.model - serialNumber = $Device.serialNumber - userPrincipalName = $Device.userPrincipalName - managedDeviceOwnerType = $Device.managedDeviceOwnerType - } - } - ) + & $Mark 'SignInBaseline' $IPAnalysis; & $Mark 'IPGuidance' $IPAnalysis; & $Mark 'IPPeers' $IPAnalysis; & $Mark 'IPVerdicts' $IPAnalysis + $IPVerdicts = @(); $IPBaseline = $null; $IPGuidance = @(); $IPPeers = @() } + Set-CIPPBecIPVerdictStamp -Results $IPDraft -Verdicts $IPVerdicts - # Geo-locate the client IPs behind rule changes, safelist changes and sent mail so + # What the attacker-side addresses did, item by item: mail opened/synced/deleted/moved/sent, + # files touched, sharing links used, Forms built - and the other mailboxes the account reaches. + & $Phase 'AttackerActivity' 'Reading what the attacker addresses opened, sent, downloaded and shared' + Write-Information 'Full scope: attacker activity' + $KnownSubjects = @{} + if ($Received.MessageIndex -is [hashtable]) { foreach ($Key in $Received.MessageIndex.Keys) { $KnownSubjects[$Key] = $Received.MessageIndex[$Key] } } + foreach ($Row in @($SentMessagesRaw | Where-Object { $_.MessageId })) { $KnownSubjects[[string]$Row.MessageId] = [string]$Row.Subject } + $Attacker = & $Collect 'AttackerActivity' { Get-CIPPBecAttackerActivity -TenantFilter $TenantFilter -UserPrincipalName $UserName -StartDate $startDate -EndDate $endDate -Heuristics $Heuristics -Verdicts $IPVerdicts -SignIns $SuspectUserSignIns -NonInteractiveSignIns $NonInteractiveSignIns -MailRecords $MailRecords -SharingChanges $SharingChanges -KnownSubjects $KnownSubjects -Anchor $UserName } + if ($Attacker.PSObject.Properties['Mail']) { + & $Mark 'AttackerMailActivity' $Attacker.Mail + & $Mark 'AttackerFileActivity' $Attacker.Files + & $Mark 'LinkUsage' $Attacker.LinkUsage + & $Mark 'FormsActivity' $Attacker.Forms + $AttackerMailActivity = @($Attacker.Mail.Data); $AttackerMailSummary = $Attacker.Mail.Summary + $AttackerFileActivity = @($Attacker.Files.Data); $AttackerFileSummary = $Attacker.Files.Summary + $LinkUsage = @($Attacker.LinkUsage.Data) + $FormsActivity = @($Attacker.Forms.Data); $FormsSummary = $Attacker.Forms.Summary + } else { + foreach ($Name in @('AttackerMailActivity', 'AttackerFileActivity', 'LinkUsage', 'FormsActivity')) { & $Mark $Name $Attacker } + $AttackerMailActivity = @(); $AttackerMailSummary = $null; $AttackerFileActivity = @(); $AttackerFileSummary = $null; $LinkUsage = @(); $FormsActivity = @(); $FormsSummary = $null + } + $Delegated = & $Collect 'DelegatedAccess' { Get-CIPPBecDelegatedAccess -TenantFilter $TenantFilter -UserPrincipalName $UserName -UserDisplayName ([string]$SuspectUserDetail.displayName) -PermissionChanges $PermissionsLog -MailActivity $MailActivity -AttackerMail $AttackerMailActivity } + & $Mark 'DelegatedAccess' $Delegated + $DelegatedAccess = @($Delegated.Data) + # The other accounts the attacker addresses reached: tenant-wide sign-ins and audit log per address + $AnalysisPeers = if ($IPAnalysis.PSObject.Properties['Peers'] -and $IPAnalysis.Peers -is [hashtable]) { $IPAnalysis.Peers } else { @{} } + $Blast = & $Collect 'BlastRadius' { Get-CIPPBecBlastRadius -TenantFilter $TenantFilter -UserId $SuspectUser -UserPrincipalName $UserName -Verdicts $IPVerdicts -Peers $AnalysisPeers -StartDate $startDate -EndDate $endDate -Heuristics $Heuristics -Anchor $UserName } + & $Mark 'BlastRadius' $Blast + $BlastRadius = @($Blast.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) { @@ -523,7 +885,7 @@ param($RawIP) if ([string]::IsNullOrWhiteSpace($RawIP)) { return $null } # same normalization the batch helper applies to its keys (strip :port and brackets) - $Clean = $ClientIpRegex.Replace(([string]$RawIP).Trim(), '${IP}') -replace '[\[\]]', '' + $Clean = ConvertTo-CIPPBecHostAddress -Address $RawIP if ([string]::IsNullOrWhiteSpace($Clean)) { return $null } return $GeoMap[$Clean] } @@ -534,7 +896,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,26 +920,30 @@ [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 + UserPrincipalName = $UserName AddedApps = @($NewSPs) MaliciousSPs = @($MaliciousSPs) SuspectUserSignIns = @($SuspectUserSignIns) SuspectUserSignInsError = $SuspectUserSignInsError - TenantLastSignIns = @($TenantLastSignIns) - LastSuspectUserLogon = @($LastSignIn) SuspectUserDevices = @($Devices) NewRules = @($RulesLog) InboxRuleChanges = @($RuleChangesLog) @@ -595,32 +961,78 @@ IntuneDevices = @($IntuneDevices) IntuneDevicesError = $IntuneDevicesError LocationAnalysis = $LocationAnalysis - AnalysisWindowDays = 7 + # The deeper collectors + MailboxState = $MailboxState + Delegations = @($Delegations) + MailboxAddIns = @($MailboxAddIns) + UserGrants = @($UserGrants) + TransportRuleChanges = @($TransportRuleChanges) + TransportRulesFlagged = @($TransportRulesFlagged) + ReceivedMailFindings = @($ReceivedMailFindings) + ReceivedMailSummary = $ReceivedMailSummary + DefenderDetections = @($DefenderDetections) + DirectoryAudits = @($DirectoryAudits) + RegisteredDevices = @($RegisteredDevices) + NonInteractiveSignIns = @($NonInteractiveSignIns) + MailActivity = @($MailActivity) + MailActivitySummary = $MailActivitySummary + # the attacker-IP picture: one verdict per address plus the evidence behind it + IPVerdicts = @($IPVerdicts) + IPBaseline = $IPBaseline + IPGuidance = @($IPGuidance) + IPPeers = @($IPPeers) + IPOverrides = @() + IPTechnicians = @($TechnicianIPs) + # item-level detail of the attacker-side addresses, and the mailboxes the account reaches + AttackerMailActivity = @($AttackerMailActivity) + AttackerMailSummary = $AttackerMailSummary + AttackerFileActivity = @($AttackerFileActivity) + AttackerFileSummary = $AttackerFileSummary + LinkUsage = @($LinkUsage) + FormsActivity = @($FormsActivity) + FormsSummary = $FormsSummary + DelegatedAccess = @($DelegatedAccess) + BlastRadius = @($BlastRadius) + RiskState = $RiskState + Completeness = [pscustomobject]$Completeness + AnalysisWindowDays = $WindowDays ExtractedAt = (Get-Date) ExtractResult = $ExtractResult } + $Score = Get-CIPPBecScore -Results $Results -Heuristics $Heuristics + $Results | Add-Member -NotePropertyName 'Score' -NotePropertyValue $Score -Force - $Entity = @{ - UserId = $SuspectUser - Results = [string]($Results | ConvertTo-Json -Depth 10 -Compress) - RowKey = $SuspectUser - PartitionKey = 'bec' - Status = 'Completed' + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Results $Results -Properties @{ + UserId = [string]$SuspectUser + UserPrincipalName = [string]$UserName + DisplayName = [string]$SuspectUserDetail.displayName + Status = 'Completed' + 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 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 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' + 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/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BPA/Push-BPACollectData.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BPA/Push-BPACollectData.ps1 deleted file mode 100644 index a3c9131c0208a..0000000000000 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/BPA/Push-BPACollectData.ps1 +++ /dev/null @@ -1,128 +0,0 @@ -function Push-BPACollectData { - <# - .FUNCTIONALITY - Entrypoint - #> - param($Item) - - $TenantName = Get-Tenants -TenantFilter $Item.Tenant - $BPATemplateTable = Get-CippTable -tablename 'templates' - $Filter = "PartitionKey eq 'BPATemplate'" - $TemplatesLoc = (Get-CIPPAzDataTableEntity @BPATemplateTable -Filter $Filter).JSON | ConvertFrom-Json - - $Templates = $TemplatesLoc | ForEach-Object { - $Template = $_ - [PSCustomObject]@{ - Data = $Template - Name = $Template.Name - Style = $Template.Style - } - } - $Table = Get-CippTable -tablename 'cachebpav2' - - $Rerun = Test-CIPPRerun -Type 'BPA' -Tenant $Item.Tenant -API $Item.Template - if ($Rerun) { - Write-Host 'Detected rerun for BPA. Exiting cleanly' - return - } - Write-Host "Working on BPA for $($TenantName.defaultDomainName) with GUID $($TenantName.customerId) - Report ID $($Item.Template)" - $Template = $Templates | Where-Object -Property Name -EQ -Value $Item.Template - # Build up the result object that will be stored in tables - $Result = @{ - Tenant = "$($TenantName.displayName)" - GUID = "$($TenantName.customerId)" - RowKey = "$($Template.Name)" - PartitionKey = "$($TenantName.customerId)" - LastRefresh = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') - } - foreach ($field in $Template.Data.Fields) { - if ($field.UseExistingInfo) { continue } - if ($Field.Where) { $filterscript = [scriptblock]::Create($Field.Where) } else { $filterscript = { $true } } - try { - switch ($field.API) { - 'Graph' { - $paramsField = @{ - uri = $field.URL - tenantid = $TenantName.defaultDomainName - } - if ($Field.Parameters.PSObject.properties.name) { - $field.Parameters | ForEach-Object { - $paramsField[$_.PSObject.properties.name] = $_.PSObject.properties.value - } - } - $FieldInfo = New-GraphGetRequest @paramsField | Where-Object $filterscript | Select-Object $field.ExtractFields - } - 'Exchange' { - Write-Host "Trying to execute $($field.Command) for $($TenantName.displayName) with GUID $($TenantName.customerId)" - if ($field.Command -notlike 'get-*') { - Write-LogMessage -API 'BPA' -tenant $TenantName.defaultDomainName -message 'The BPA only supports get- exchange commands. A set or update command was used.' -sev Error - break - } else { - $paramsField = @{ - tenantid = $TenantName.defaultDomainName - cmdlet = $field.Command - } - if ($Field.Parameters) { $paramsField.'cmdParams' = $field.parameters } - $FieldInfo = New-ExoRequest @paramsField | Where-Object $filterscript | Select-Object $field.ExtractFields - } - } - 'CIPPFunction' { - if ($field.Command -notlike 'get-CIPP*') { - Write-LogMessage -API 'BPA' -tenant $TenantName.defaultDomainName -message 'The BPA only supports get-CIPP commands. A set or update command was used, or a command that is not allowed.' -sev Error - break - } - $paramsField = @{ - TenantFilter = $TenantName.defaultDomainName - } - if ($field.Parameters.PSObject.properties.name) { - $field.Parameters | ForEach-Object { - $paramsField[$_.PSObject.properties.name] = $_.PSObject.properties.value - } - } - $FieldInfo = & $field.Command @paramsField | Where-Object $filterscript | Select-Object $field.ExtractFields - } - } - } catch { - Write-Information "Error getting $($field.Name) in $($field.api) for $($TenantName.displayName) with GUID $($TenantName.customerId). Error: $($_.Exception.Message)" - Write-LogMessage -API 'BPA' -tenant $TenantName.defaultDomainName -message "Error getting $($field.Name) for $($TenantName.displayName) with GUID $($TenantName.customerId). Error: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) - $FieldInfo = 'FAILED' - $field.StoreAs = 'string' - } - try { - switch -Wildcard ($field.StoreAs) { - '*bool' { - if ($field.ExtractFields.Count -gt 1) { - Write-LogMessage -API 'BPA' -tenant $TenantName.defaultDomainName -message "The BPA only supports 1 field for a bool. $($field.ExtractFields.Count) fields were specified." -sev Error - break - } - if ($null -eq $FieldInfo.$($field.ExtractFields)) { $FieldInfo = $false } - - $Result.Add($field.Name, [bool]$FieldInfo.$($field.ExtractFields)) - } - 'JSON' { - if ($null -eq $FieldInfo) { $JsonString = '{}' } else { $JsonString = (ConvertTo-Json -Depth 15 -InputObject $FieldInfo -Compress) } - Write-Host "Adding $($field.Name) to table with value $JsonString" - $Result.Add($field.Name, $JSONString) - } - 'string' { - $Result.Add($field.Name, [string]$FieldInfo) - } - 'percentage' { - - } - } - } catch { - Write-LogMessage -API 'BPA' -tenant $TenantName.defaultDomainName -message "Error storing $($field.Name) for $($TenantName.displayName) with GUID $($TenantName.customerId). Error: $($_.Exception.Message)" -sev Error -LogData (Get-CippException -Exception $_) - $Result.Add($field.Name, 'FAILED') - } - - } - - if ($Result) { - try { - Add-CIPPAzDataTableEntity @Table -Entity $Result -Force - } catch { - Write-LogMessage -API 'BPA' -tenant $TenantName.defaultDomainName -message "Error getting saving data for $($template.Name) - $($TenantName.customerId). Error: $($_.Exception.Message)" -LogData (Get-CippException -Exception $_) -sev Error - } - } -} diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineCacheRefresh.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineCacheRefresh.ps1 index 6b5713edd351f..a9c534c99f722 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineCacheRefresh.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineCacheRefresh.ps1 @@ -25,6 +25,10 @@ function Push-CIPPBaselineCacheRefresh { Write-Information "Baseline trend point skipped: $($_.Exception.Message)" } + try { Send-CIPPBaselineAlertDigest } catch { + Write-LogMessage -API 'Baselines' -message "Baseline alert digest failed: $($_.Exception.Message)" -Sev 'Error' + } + $Impacted = @{} foreach ($ActivityResult in @($Item.Results)) { foreach ($Record in @($ActivityResult)) { diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1 index a88d744c8fa5d..3802a69c9f1bc 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Baselines/Push-CIPPBaselineStandard.ps1 @@ -63,6 +63,19 @@ function Push-CIPPBaselineStandard { Write-LogMessage -API 'Baselines' -tenant $Item.Item.TenantFilter -message "The refreshed cache still grades `"$($Item.Item.Standard)`" as drifted after remediation - either the write did not take effect or the API is lagging beyond the retry window; the next compare may re-report drift until the scheduled collection." -Sev 'Warning' } } + if ($Verdict -and $Verdict.Compliant) { + # GradeOnly persists nothing - the row still carries the optimistic + # PendingVerification the initial remediation wrote. A clean verdict + # here (first pass or after backoff) confirms the fix, so clear it. + $ResolvedTable = Get-CippTable -tablename 'BaselineAlignment' + $SafeTenant = ConvertTo-CIPPODataFilterValue -Value "$($Item.Item.TenantFilter)" -Type String + $SafeRowKey = ConvertTo-CIPPODataFilterValue -Value ("$($Item.Item.Standard)" -replace '#', '~') -Type String + $ResolvedRow = Get-CIPPAzDataTableEntity @ResolvedTable -Filter "PartitionKey eq '$SafeTenant' and RowKey eq '$SafeRowKey'" | Select-Object -First 1 + if ($ResolvedRow -and $ResolvedRow.PendingVerification) { + $ResolvedRow.PendingVerification = $false + $null = Add-CIPPAzDataTableEntity @ResolvedTable -Entity $ResolvedRow -Force + } + } } catch { Write-Information "Baselines: post-remediation cache verification for $($Item.Item.Standard) failed: $($_.Exception.Message)" } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 index cfb5d02467384..d9b2867153339 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserDomain.ps1 @@ -226,7 +226,7 @@ function Push-DomainAnalyserDomain { if ($Domain -match 'onmicrosoft.com' -and $Domain -notmatch 'mail.onmicrosoft.com') { $DKIMSelector1Value = "selector1-$($Domain -replace '\.', '-' )" $DKIMSelector2Value = "selector2-$($Domain -replace '\.', '-' )" - $DkimParams.Add('Selectors', @("$DKIMSelector1Value", "$DKIMSelector2Value")) + $DkimParams.Selectors = @("$DKIMSelector1Value", "$DKIMSelector2Value") } $DkimRecord = Read-DkimRecord @DkimParams -ErrorAction Stop diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 index 20bba052712a5..990dc3ef78c4a 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Domain Analyser/Push-DomainAnalyserTenant.ps1 @@ -16,8 +16,6 @@ function Push-DomainAnalyserTenant { Write-LogMessage -API 'DomainAnalyser' -tenant $Tenant.defaultDomainName -tenantid $Tenant.customerId -message "Cleaning up $CleanupCount domain(s) for excluded tenant" -sev Info Remove-CIPPAzDataTableEntity -Force @DomainTable -Entity $CleanupRows } - } elseif ($Tenant.GraphErrorCount -gt 50) { - return } else { try { # Get domains from cached database instead of making Graph API calls diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 index d95878b545a93..dcc2014a47462 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetCalendarPermissionsBatch.ps1 @@ -74,7 +74,7 @@ function Push-GetCalendarPermissionsBatch { } Write-Information "Phase 1: Bulk Get-MailboxFolderStatistics for $($CacheMissMailboxes.Count) mailboxes" - $FolderStatsResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($FolderStatsRequests) -Select 'Name,FolderType' + $FolderStatsResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($FolderStatsRequests) -Select 'Name,FolderType' -MaxConcurrency 5 # One call returns EVERY calendar folder flattened under one OperationGuid, so # last-wins cached whatever the mailbox listed last - 'United States holidays' for @@ -140,7 +140,7 @@ function Push-GetCalendarPermissionsBatch { if ($PermissionRequests) { Write-Information "Phase 2: Bulk Get-MailboxFolderPermission for $(@($PermissionRequests).Count) mailboxes" - $PermissionResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($PermissionRequests) -useSystemMailbox $true + $PermissionResults = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($PermissionRequests) -useSystemMailbox $true -MaxConcurrency 5 foreach ($Perm in $PermissionResults) { if ($Perm.error) { diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetMailboxPermissionsBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetMailboxPermissionsBatch.ps1 index a47f664de2d3e..a905dff2d345e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetMailboxPermissionsBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Mailbox Permissions/Push-GetMailboxPermissionsBatch.ps1 @@ -41,7 +41,7 @@ function Push-GetMailboxPermissionsBatch { Write-Information "Built $($ExoBulkRequests.Count) bulk requests for batch $BatchNumber" # Execute bulk request for this batch with ReturnWithCommand to separate permission types - $MailboxPermissions = New-ExoBulkRequest -cmdletArray @($ExoBulkRequests) -tenantid $TenantFilter -ReturnWithCommand $true + $MailboxPermissions = New-ExoBulkRequest -cmdletArray @($ExoBulkRequests) -tenantid $TenantFilter -ReturnWithCommand $true -MaxConcurrency 5 Write-Information "Bulk request completed. Result type: $($MailboxPermissions.GetType().Name)" if ($MailboxPermissions -is [hashtable]) { diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 index b1f17b64e77a5..6c88e3f6950cd 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-ExecOnboardTenantQueue.ps1 @@ -501,6 +501,23 @@ function Push-ExecOnboardTenantQueue { } if ($OnboardingSteps.Step4.Status -eq 'succeeded') { + $SelectedGroupIds = if ($TenantOnboarding.TenantGroups) { $TenantOnboarding.TenantGroups | ConvertFrom-Json } + if ($SelectedGroupIds) { + $GroupTable = Get-CIPPTable -tablename 'TenantGroups' + $MembersTable = Get-CIPPTable -tablename 'TenantGroupMembers' + # Only static groups, dynamic group membership is managed by the orchestrator + $SelectedGroups = Get-CIPPAzDataTableEntity @GroupTable -Filter "PartitionKey eq 'TenantGroup'" | Where-Object { $_.GroupType -ne 'dynamic' -and $SelectedGroupIds -contains $_.RowKey } + foreach ($Group in $SelectedGroups) { + Add-CIPPAzDataTableEntity @MembersTable -Entity @{ + PartitionKey = 'Member' + RowKey = '{0}-{1}' -f $Group.RowKey, $Tenant.customerId + GroupId = $Group.RowKey + customerId = $Tenant.customerId + } -Force + $Logs.Add([PSCustomObject]@{ Date = (Get-Date).ToUniversalTime(); Log = "Added tenant to group '$($Group.Name)'" }) + } + $null = Get-TenantGroups -SkipCache + } if ($StandardsExcludeAllTenants -eq $true) { $GroupTable = Get-CIPPTable -tablename 'TenantGroups' $MembersTable = Get-CIPPTable -tablename 'TenantGroupMembers' diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 index cac14c52d3e19..b31519d977756 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-GetMailboxRulesBatch.ps1 @@ -31,7 +31,7 @@ function Push-GetMailboxRulesBatch { } } - $Rules = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Request) | Where-Object { $_.Identity } + $Rules = New-ExoBulkRequest -tenantid $TenantFilter -cmdletArray @($Request) -MaxConcurrency 5 | Where-Object { $_.Identity } Write-Information "Retrieved $($Rules.Count) rules from batch $BatchNumber/$TotalBatches" diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-SchedulerCIPPNotifications.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-SchedulerCIPPNotifications.ps1 index cf36ed310c70e..e8f027391ce3e 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-SchedulerCIPPNotifications.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Push-SchedulerCIPPNotifications.ps1 @@ -145,7 +145,9 @@ function Push-SchedulerCIPPNotifications { $Data = $g.Group | Select-Object Message, API, Tenant, Username, Severity $HTMLContent = New-CIPPAlertTemplate -Data $Data -Format 'psa' -InputObject 'table' -CIPPURL $CIPPURL $Title = "$tenant CIPP Alert: Alerts found starting at $((Get-Date).AddMinutes(-15))" - Send-CIPPAlert -Type 'psa' -Title $Title -HTMLContent $HTMLContent.htmlcontent -TenantFilter $tenant -APIName 'Alerts' + # The visible title contains a timestamp, so use a stable per-tenant key for PSA + # consolidation. PSA integrations that ignore the optional key remain unchanged. + Send-CIPPAlert -Type 'psa' -Title $Title -HTMLContent $HTMLContent.htmlcontent -TenantFilter $tenant -APIName 'Alerts' -PSAConsolidationKey "$tenant|Alerts" & $MarkSent $g.Group $LogTable $Data = $null; $HTMLContent = $null } diff --git a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 index fff94115fdb04..7036846ab86d6 100644 --- a/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 +++ b/Modules/CIPPActivityTriggers/Public/Entrypoints/Activity Triggers/Tests/Push-CIPPTestsList.ps1 @@ -37,7 +37,7 @@ function Push-CIPPTestsList { # Emit one task per suite — suite names must match the ValidateSet in Invoke-CIPPTestCollection. # Function discovery happens inside Invoke-CIPPTestCollection via Get-Command (path-independent). - $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom', 'E8') + $Suites = @('ZTNA', 'ORCA', 'EIDSCA', 'CISA', 'CIS', 'SMB1001', 'CopilotReadiness', 'GenericTests', 'Custom', 'E8', 'SecuritySimulations') # Optional caller-supplied suite filter (e.g. a Custom-only run). When present, restrict # the emitted suites to the requested subset so we don't spin up every suite unnecessarily. diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 index 35666a10d4ba9..4a04ff384db44 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAdminPassword.ps1 @@ -38,9 +38,7 @@ function Get-CIPPAlertAdminPassword { $AlertData = @() } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get admin password changes for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 index dfc1459d293c8..bb4fc57f70714 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertApnCertExpiry.ps1 @@ -26,9 +26,7 @@ function Get-CIPPAlertApnCertExpiry { $AlertData = if ($Apn.expirationDateTime -lt (Get-Date).AddDays($expiryDays) -and $Apn.expirationDateTime -gt (Get-Date).AddDays(-7)) { $Apn | Select-Object -Property appleIdentifier, expirationDateTime } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { #no error because if a tenant does not have an APN, it'll error anyway. diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppCertificateExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppCertificateExpiry.ps1 index e31714f632dd2..8a742b1f02fd4 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppCertificateExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppCertificateExpiry.ps1 @@ -13,11 +13,14 @@ function Get-CIPPAlertAppCertificateExpiry { $Now = Get-Date $AlertData = @() + # A cache read that fails is 'could not check', not 'nothing expiring': skip the reconcile so open items stay open. + $ReadFailed = $false try { $appList = New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Apps' } catch { $appList = @() + $ReadFailed = $true } $AppAlertData = foreach ($App in $appList) { @@ -40,6 +43,7 @@ function Get-CIPPAlertAppCertificateExpiry { $servicePrincipals = New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'ServicePrincipals' } catch { $servicePrincipals = @() + $ReadFailed = $true } $SamlAlertData = foreach ($ServicePrincipal in $servicePrincipals) { @@ -63,7 +67,9 @@ function Get-CIPPAlertAppCertificateExpiry { @($AppAlertData) @($SamlAlertData) ) | Where-Object { $null -ne $_ } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData + if ($ReadFailed) { + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message 'App certificate expiry alert skipped: the application or service principal cache could not be read' -sev Info + return } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 index 7e741af1e5d2b..7c565cde491bd 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppSecretExpiry.ps1 @@ -43,7 +43,5 @@ function Get-CIPPAlertAppSecretExpiry { } } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppleTerms.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppleTerms.ps1 index f5678cd954210..773603abaaa8f 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppleTerms.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertAppleTerms.ps1 @@ -35,6 +35,6 @@ function Get-CIPPAlertAppleTerms { LastSyncTriggeredDateTime = $AppleTerms.lastSyncTriggeredDateTime Tenant = $TenantFilter } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertArchiveQuota.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertArchiveQuota.ps1 index 8c9499d49a53f..700f369520849 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertArchiveQuota.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertArchiveQuota.ps1 @@ -49,7 +49,10 @@ function Get-CIPPAlertArchiveQuota { Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Archive quota Alert: Unable to get archive mailboxes: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage return } - if ($ArchiveMailboxes.Count -eq 0) { return } + if ($ArchiveMailboxes.Count -eq 0) { + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data @() + return + } # Archive size only comes from Get-MailboxStatistics. Batch it with an operation guid per # mailbox so each result maps back to its mailbox, the same pattern the reporting-DB cache uses. @@ -105,7 +108,5 @@ function Get-CIPPAlertArchiveQuota { } } - if ($OverQuota) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 index 0d0fb4281d13d..11ce91f892b7e 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertCheckExtension.ps1 @@ -49,9 +49,7 @@ function Get-CIPPAlertCheckExtension { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData -Append # Advance the watermark so the next run only picks up alerts newer than # this run. Without this, $Since always fell back to the default window diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 index d76dbe172a656..72e7e2cc5a9e4 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderAlerts.ps1 @@ -49,9 +49,7 @@ function Get-CIPPAlertDefenderAlerts { Tenant = $TenantFilter } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { # Commented out due to potential licensing spam diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 index 5d6b405e98005..ef601fdc33472 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderIncidents.ps1 @@ -36,9 +36,7 @@ function Get-CIPPAlertDefenderIncidents { Tenant = $TenantFilter } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { # Pretty sure this one is gonna be spammy cause of licensing issues, so it's commented out -Bobby diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 index d651440f0cd3e..27b5ac315ad83 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderMalware.ps1 @@ -27,9 +27,7 @@ function Get-CIPPAlertDefenderMalware { TenantId = $_.tenantId } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 index ee8d556ba7714..bbee97e05cb3c 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDefenderStatus.ps1 @@ -26,9 +26,7 @@ function Get-CIPPAlertDefenderStatus { TenantId = $_.tenantId } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 index 10320d3bf030c..01ce26f829446 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDepTokenExpiry.ps1 @@ -30,9 +30,7 @@ function Get-CIPPAlertDepTokenExpiry { $Dep | Select-Object -Property tokenName, @{Name = 'Message'; Expression = { $Message } } } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 index 313fdaad06790..d96a97a7d63d0 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceCompliance.ps1 @@ -13,9 +13,7 @@ function Get-CIPPAlertDeviceCompliance { ) try { $AlertData = New-GraphGETRequest -uri "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?`$filter=complianceState eq 'noncompliant'&`$select=id,deviceName,managedDeviceOwnerType,complianceState,lastSyncDateTime&`$top=999" -tenantid $TenantFilter - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get compliance state for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceComplianceGracePeriod.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceComplianceGracePeriod.ps1 index 02acf09a1fd12..f337a3ed23f89 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceComplianceGracePeriod.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertDeviceComplianceGracePeriod.ps1 @@ -45,9 +45,7 @@ function Get-CIPPAlertDeviceComplianceGracePeriod { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get compliance grace period state for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 index e72b97f9a703e..253ccf63a32ab 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertEntraConnectSyncStatus.ps1 @@ -32,9 +32,9 @@ function Get-CIPPAlertEntraConnectSyncStatus { LastDirectorySync = $SyncDateTime Tenant = $TenantFilter } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get Entra Connect Sync Status for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertEntraLicenseUtilization.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertEntraLicenseUtilization.ps1 index 92cf90a31a9d7..f516b8eacfceb 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertEntraLicenseUtilization.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertEntraLicenseUtilization.ps1 @@ -60,9 +60,7 @@ function Get-CIPPAlertEntraLicenseUtilization { } ) - if ($AlertData.Count -gt 0) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 index 200adf97719ba..dab15a864c86c 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertExpiringLicenses.ps1 @@ -52,6 +52,7 @@ function Get-CIPPAlertExpiringLicenses { } [PSCustomObject]@{ + Id = $Term.SubscriptionId Message = $Message License = $_.License SkuId = $_.skuId @@ -69,12 +70,11 @@ function Get-CIPPAlertExpiringLicenses { } ) - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -error $_ + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Expiring licenses alert failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage throw } } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 index daabacf92197a..1e658ab133094 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGlobalAdminAllowList.ps1 @@ -75,9 +75,9 @@ function Get-CIPPAlertGlobalAdminAllowList { Tenant = $TenantFilter }) } - - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check approved Global Admins: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGlobalAdminNoAltEmail.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGlobalAdminNoAltEmail.ps1 index 3d3a7db653d92..1bd295f71cceb 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGlobalAdminNoAltEmail.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGlobalAdminNoAltEmail.ps1 @@ -30,8 +30,8 @@ function Get-CIPPAlertGlobalAdminNoAltEmail { Tenant = $TenantFilter } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { Write-LogMessage -message "Failed to check alternate email status for Global Admins: $($_.exception.message)" -API 'Global Admin Alt Email Alerts' -tenant $TenantFilter -sev Error } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 index 0feeff359001f..c880e995bfd16 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertGroupMembershipChange.ps1 @@ -38,9 +38,7 @@ function Get-CIPPAlertGroupMembershipChange { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check group membership changes for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 index 0e5886f927a22..a1e1928b81bbc 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertHuntressRogueApps.ps1 @@ -84,8 +84,8 @@ function Get-CIPPAlertHuntressRogueApps { 'Listed On' = $RogueApp.Added } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { #$ErrorMessage = Get-CippException -Exception $_ #Write-AlertMessage -tenant $($TenantFilter) -message "Failed to check for rogue apps for $($TenantFilter): $($ErrorMessage.NormalizedError)" diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 index 54f40db6c6ee3..c9404e3233ec7 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveGuestUsers.ps1 @@ -81,9 +81,7 @@ function Get-CIPPAlertInactiveGuestUsers { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 index 221e9a4444532..3176ab4c6e546 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveLicensedUsers.ps1 @@ -100,9 +100,7 @@ function Get-CIPPAlertInactiveLicensedUsers { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveSharePointSites.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveSharePointSites.ps1 index ba301c522572e..8d72d68b9b9d9 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveSharePointSites.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveSharePointSites.ps1 @@ -88,9 +88,7 @@ function Get-CIPPAlertInactiveSharePointSites { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-AlertMessage -message "Inactive SharePoint sites alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveTeamsSites.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveTeamsSites.ps1 index 38efa0383f51a..adf108b21904e 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveTeamsSites.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveTeamsSites.ps1 @@ -91,9 +91,7 @@ function Get-CIPPAlertInactiveTeamsSites { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-AlertMessage -message "Inactive TeamsSite alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 index d61205f23b80c..46f31f567134c 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertInactiveUsers.ps1 @@ -77,9 +77,7 @@ function Get-CIPPAlertInactiveUsers { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntuneApprovalRequests.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntuneApprovalRequests.ps1 index 4c2fe0844787a..d4ebc6d7f658a 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntuneApprovalRequests.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntuneApprovalRequests.ps1 @@ -32,9 +32,7 @@ function Get-CIPPAlertIntuneApprovalRequests { @{Name = 'Message'; Expression = { $Message } } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to check Intune multi-admin approval requests for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 index bc672c406a38f..00d5ddeb19db5 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertIntunePolicyConflicts.ps1 @@ -63,6 +63,7 @@ function Get-CIPPAlertIntunePolicyConflicts { } $Issues = [System.Collections.Generic.List[object]]::new() + $ReadFailed = $false if ($Config.IncludePolicies -and $AlertableStatuses) { $PolicySources = @( @@ -96,6 +97,7 @@ function Get-CIPPAlertIntunePolicyConflicts { } } } catch { + $ReadFailed = $true $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to read cached $($Source.Kind) policy states: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } @@ -122,16 +124,20 @@ function Get-CIPPAlertIntunePolicyConflicts { }) } } catch { + $ReadFailed = $true $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Failed to read cached Intune app install status: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage } } - if (-not $Issues) { + # A failed cache read means the picture is incomplete: "could not check" is not "clear". + if ($ReadFailed) { return } - if (-not $Config.AlertEachIssue) { + if (-not $Issues) { + $AlertData = $null + } elseif (-not $Config.AlertEachIssue) { $PolicyCount = ($Issues | Where-Object { $_.Type -eq 'Policy' }).Count $AppCount = ($Issues | Where-Object { $_.Type -eq 'Application' }).Count @@ -146,7 +152,5 @@ function Get-CIPPAlertIntunePolicyConflicts { $AlertData = $Issues } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLicenseAssignmentErrors.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLicenseAssignmentErrors.ps1 index 0c44125c4b034..676b6fa6e092f 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLicenseAssignmentErrors.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLicenseAssignmentErrors.ps1 @@ -90,10 +90,7 @@ function Get-CIPPAlertLicenseAssignmentErrors { } } - # If errors are found, write alert - if ($LicenseAssignmentErrors) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $LicenseAssignmentErrors - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $LicenseAssignmentErrors } catch { Write-LogMessage -message "Failed to check license assignment errors: $($_.exception.message)" -API 'License Assignment Alerts' -tenant $TenantFilter -sev Error diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLicensedUsersWithRoles.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLicensedUsersWithRoles.ps1 index d97625653c8e1..3f9b8167c8585 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLicensedUsersWithRoles.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLicensedUsersWithRoles.ps1 @@ -15,22 +15,23 @@ function Get-CIPPAlertLicensedUsersWithRoles { $LicensedUsers = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/users?`$top=999&`$select=userPrincipalName,assignedLicenses,displayName" -tenantid $TenantFilter | Where-Object { $_.assignedLicenses -and $_.assignedLicenses.Count -gt 0 } if (-not $LicensedUsers -or $LicensedUsers.Count -eq 0) { Write-Information "No licensed users found for tenant $TenantFilter" - return $true + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $null + return } # Get all directory roles with their members $DirectoryRoles = New-GraphGetRequest -uri "https://graph.microsoft.com/beta/directoryRoles?`$expand=members" -tenantid $TenantFilter if (-not $DirectoryRoles -or $DirectoryRoles.Count -eq 0) { Write-Information "No directory roles found for tenant $TenantFilter" + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $null return } $UsersToAlertOn = $LicensedUsers | Where-Object { $_.userPrincipalName -in $DirectoryRoles.members.userPrincipalName } - if ($UsersToAlertOn.Count -gt 0) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $UsersToAlertOn - } else { + if ($UsersToAlertOn.Count -eq 0) { Write-Information "No licensed users with roles found for tenant $TenantFilter" } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $UsersToAlertOn } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 index 9699e2ca1b6be..15ded72b23724 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLongLivedAppCredentials.ps1 @@ -17,11 +17,11 @@ function Get-CIPPAlertLongLivedAppCredentials { $Apps = @(New-GraphGetRequest -uri "https://graph.microsoft.com/beta/applications?`$select=id,appId,displayName,passwordCredentials,keyCredentials&`$top=999" -tenantid $TenantFilter -AsApp $true) $NowUtc = [datetime]::UtcNow - $DatePartition = (Get-Date -UFormat '%Y%m%d').ToString() $CredTypeMap = @( @{ Property = 'passwordCredentials'; TypeLabel = 'Secret' } @{ Property = 'keyCredentials'; TypeLabel = 'Certificate' } ) + $AlertData = [System.Collections.Generic.List[object]]::new() foreach ($App in @($Apps)) { foreach ($ct in $CredTypeMap) { @@ -33,8 +33,6 @@ function Get-CIPPAlertLongLivedAppCredentials { if ($endUtc -le $startUtc -or $endUtc -le $NowUtc) { continue } $months = (New-TimeSpan -Start $startUtc -End $endUtc).TotalDays / 30.4375 if ($months -gt $MaxMonths) { - $keyId = if ($Cred.keyId) { "$($Cred.keyId)" } else { 'unknown' } - $tracePartition = "$DatePartition-$($App.id)-$keyId" -replace '[/\\#?]', '_' $oneFinding = [PSCustomObject]@{ AppDisplayName = $App.displayName AppId = $App.appId @@ -46,11 +44,13 @@ function Get-CIPPAlertLongLivedAppCredentials { ValidityMonths = [math]::Round([double]$months, 2) MaxMonthsAllowed = $MaxMonths } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $oneFinding -PartitionKey $tracePartition + $AlertData.Add($oneFinding) } } } } + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Excessive secret validity alert failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 index 6746485852218..decd2785a8a30 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLowDomainScore.ps1 @@ -13,6 +13,11 @@ function Get-CIPPAlertLowDomainScore { ) $DomainData = Get-CIPPDomainAnalyser -TenantFilter $TenantFilter + if (-not $DomainData) { + # No analyser results yet is 'could not check', not 'every domain is fine'. + Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message 'Low domain score alert skipped: no domain analyser data available yet' -sev Info + return + } $LowScoreDomains = $DomainData | Where-Object { $_.ScorePercentage -lt $InputValue -and $_.ScorePercentage -ne '' -and $_.Domain -notlike '*.onmicrosoft.com' -and $_.Domain -notlike '*.mail.onmicrosoft.com' } | ForEach-Object { [PSCustomObject]@{ Message = "$($_.Domain): Domain security score is $($_.ScorePercentage)%, which is below the threshold of $InputValue%. Issues: $($_.ScoreExplanation)" @@ -23,7 +28,5 @@ function Get-CIPPAlertLowDomainScore { } } - if ($LowScoreDomains) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $LowScoreDomains - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $LowScoreDomains } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 index db205211cca04..6f2a6cd38d10e 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertLowTenantAlignment.ps1 @@ -42,9 +42,7 @@ function Get-CIPPAlertLowTenantAlignment { } } - if ($LowAlignmentAlerts.Count -gt 0) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $LowAlignmentAlerts - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $LowAlignmentAlerts } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 index 429c04de6202c..3bda1a35cf261 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMFAAdmins.ps1 @@ -75,9 +75,7 @@ function Get-CIPPAlertMFAAdmins { }) } - if ($AlertData.Count -gt 0) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } else { Write-LogMessage -message 'Potentially using Duo for MFA, could not check MFA status for Admins with 100% accuracy' -API 'MFA Alerts - Informational' -tenant $TenantFilter -sev Info } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMFAAlertUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMFAAlertUsers.ps1 index 4eb54bdf05d30..ca87c004b9d96 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMFAAlertUsers.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMFAAlertUsers.ps1 @@ -21,6 +21,52 @@ function Get-CIPPAlertMFAAlertUsers { Where-Object { $_.userDisplayName -ne 'On-Premises Directory Synchronization Service Account' -and $_.userPrincipalName -notmatch '^package_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}@' } | Select-Object @{n = 'UPN'; e = { $_.userPrincipalName } }, @{n = 'DisplayName'; e = { $_.userDisplayName } } } + + # Give new accounts a grace period to register MFA before they are alerted on. + # Only suppress when we positively know createdDateTime is within the window; + # unknown age (missing user) leaves them in the alert list. + $NewUserGraceDays = [int]($InputValue ?? 0) + if ($Users -and $NewUserGraceDays -gt 0) { + $CreatedByUpn = @{} + try { + foreach ($CachedUser in @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' -Fields 'userPrincipalName', 'createdDateTime')) { + if ($CachedUser.userPrincipalName) { + $CreatedByUpn[$CachedUser.userPrincipalName] = $CachedUser.createdDateTime + } + } + } catch { + Write-Host "Could not load user createdDateTime from reporting DB for tenant '$TenantFilter': $_" + } + + # No usable cache — same fields from live Graph, same filter rules below. + if ($CreatedByUpn.Count -eq 0) { + try { + foreach ($LiveUser in @(New-GraphGETRequest -uri "https://graph.microsoft.com/beta/users?`$select=userPrincipalName,createdDateTime" -tenantid $TenantFilter)) { + if ($LiveUser.userPrincipalName) { + $CreatedByUpn[$LiveUser.userPrincipalName] = $LiveUser.createdDateTime + } + } + } catch { + Write-Host "Could not load user createdDateTime from Graph for tenant '$TenantFilter': $_" + } + } + + if ($CreatedByUpn.Count -gt 0) { + $Cutoff = (Get-Date).ToUniversalTime().AddDays(-$NewUserGraceDays) + $Users = @($Users | Where-Object { + $Created = $CreatedByUpn[$_.UPN] + if (-not $Created) { + $true + } else { + try { + ([datetime]$Created).ToUniversalTime() -lt $Cutoff + } catch { + $true + } + } + }) + } + } Write-Host "Completed MFA status check for tenant '$TenantFilter'. Found $($Users.Count) users without MFA registered." if ($Users) { @@ -31,8 +77,8 @@ function Get-CIPPAlertMFAAlertUsers { } } Write-Host 'Writing alert trace' - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { Write-LogMessage -message "Failed to check MFA status for all users: $($_.exception.message)" -API 'MFA Alerts - Informational' -tenant $TenantFilter -sev Error diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 index 056928e9c2afa..f4d9daab7e3dc 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertMXRecordChanged.ps1 @@ -72,9 +72,7 @@ function Get-CIPPAlertMXRecordChanged { Add-CIPPAzDataTableEntity @CacheTable -Entity $CacheEntity -Force } - if ($ChangedDomains) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $ChangedDomains - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $ChangedDomains } catch { Write-LogMessage -message "Failed to check MX record changes: $($_.Exception.Message)" -API 'MX Record Alert' -tenant $TenantFilter -sev Error } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 index b8e0252a39396..4771de839919e 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewAppApproval.ps1 @@ -56,9 +56,9 @@ function Get-CIPPAlertNewAppApproval { $AlertData.Add($Message) } } - - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { } } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 index 0bdb729a1581a..9fde9812a4913 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewMFADevice.ps1 @@ -48,9 +48,7 @@ function Get-CIPPAlertNewMFADevice { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 index 46a8033f3c88e..b2c4caeeb91f7 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRiskyUsers.ps1 +++ b/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,24 @@ 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 -Redacted -Headers 'Alert Engine' -APIName 'Alert Engine' + $Containment = @(foreach ($Row in @($Rows)) { "$($Row.Action) ($($Row.state)): $($Row.resultText)" }) -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,15 +78,14 @@ function Get-CIPPAlertNewRiskyUsers { IsProcessing = $_.isProcessing RiskHistory = $RiskHistory Severity = $Severity + Containment = $Containment } Tenant = $TenantFilter } } - - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } } + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get risky users for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRole.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRole.ps1 index 257f9a26e45bb..0c496c0527251 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRole.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewRole.ps1 @@ -40,8 +40,8 @@ function Get-CIPPAlertNewRole { } } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get role changes for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewShadowAITool.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewShadowAITool.ps1 index 86eb83645faa7..025915863acf5 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewShadowAITool.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNewShadowAITool.ps1 @@ -117,7 +117,7 @@ function Get-CIPPAlertNewShadowAITool { Add-CIPPAzDataTableEntity @DeltaTable -Entity $DeltaEntity -Force # First run establishes the baseline without alerting. - if (-not $PreviousRow) { return } + if (-not $PreviousRow) { $NewToolNames = @() } # Optionally skip tools that are marked as sanctioned for this tenant. if ($InputValue -eq $true) { @@ -139,8 +139,8 @@ function Get-CIPPAlertNewShadowAITool { 'Tenant' = $TenantFilter } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check for new Shadow AI tools for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 index 8ed68ab257822..b6c231b6fdb68 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertNoCAConfig.ps1 @@ -25,9 +25,9 @@ function Get-CIPPAlertNoCAConfig { Message = 'Conditional Access is available, but no policies could be found.' Tenant = $TenantFilter } - - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 index de865c1a9015f..84b22aa777986 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOneDriveLongPaths.ps1 @@ -66,9 +66,7 @@ function Get-CIPPAlertOneDriveLongPaths { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-AlertMessage -message "OneDrive long paths alert failed: $($ErrorMessage.NormalizedError)" -tenant $TenantFilter -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 index 66793768e3362..4d92054ff0e43 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOnedriveQuota.ps1 @@ -46,7 +46,5 @@ function Get-CIPPAlertOneDriveQuota { } #If the quota is over the threshold, send an alert - if ($OverQuota) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 index 527f9d46e3bdb..99b3d1600ac12 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertOverusedLicenses.ps1 @@ -27,9 +27,7 @@ function Get-CIPPAlertOverusedLicenses { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Overused Licenses Alert Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertPermanentActiveAdminAssigned.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertPermanentActiveAdminAssigned.ps1 index 6cad3c9cdaa81..979dc8b433e2d 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertPermanentActiveAdminAssigned.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertPermanentActiveAdminAssigned.ps1 @@ -84,9 +84,7 @@ function Get-CIPPAlertPermanentActiveAdminAssigned { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not check permanent admin assignments for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 index b9607981ecdcd..e7a4df6411b8d 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuarantineReleaseRequests.ps1 @@ -18,14 +18,13 @@ } try { - # The received-date window has to be wide enough to catch a release request raised some time after - # the message was quarantined. The old 6-hour window missed most of them; a one-day window suits an - # hourly-scheduled alert. (The Quarantine page applies no received-date filter, which is why the - # request is visible there while no webhook or email is ever sent.) + # EXO can only filter on when the message was received, not when release was requested, and users + # often ask days after the message was quarantined. Cover the full 30 days EXO allows so every + # pending request is seen; Write-AlertTrace keeps an already-alerted request from re-alerting. $cmdParams = @{ PageSize = 1000 ReleaseStatus = 'Requested' - StartReceivedDate = (Get-Date).AddDays(-1) + StartReceivedDate = (Get-Date).AddDays(-30) EndReceivedDate = (Get-Date) } $RequestedReleases = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-QuarantineMessage' -cmdParams $cmdParams -ErrorAction Stop | Select-Object -ExcludeProperty *data.type* | Sort-Object -Property ReceivedTime @@ -51,9 +50,9 @@ Tenant = $TenantFilter } } - - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "QuarantineReleaseRequests: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 index 91691e65b6c6c..125a711366a01 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertQuotaUsed.ps1 @@ -44,7 +44,5 @@ function Get-CIPPAlertQuotaUsed { } } } - if ($OverQuota) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $OverQuota } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 index 5cda78a798c89..3cf1cd3731a8c 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertReportOnlyCA.ps1 @@ -32,8 +32,8 @@ function Get-CIPPAlertReportOnlyCA { Tenant = $TenantFilter } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertRestrictedUsers.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertRestrictedUsers.ps1 index 3fe718f8dd9d6..ca70160b3c247 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertRestrictedUsers.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertRestrictedUsers.ps1 @@ -34,8 +34,8 @@ Tenant = $TenantFilter } } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { # $ErrorMessage = Get-CippException -Exception $_ # Write-LogMessage -tenant $($TenantFilter) -message "Could not get restricted users for $($TenantFilter): $($ErrorMessage.NormalizedError)" -severity 'Error' -API 'Get-CIPPAlertRestrictedUsers' -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 index 009f1cc7e389f..b32e5ee170cb2 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertRoleEscalableGroups.ps1 @@ -117,11 +117,10 @@ function Get-CIPPAlertRoleEscalableGroups { } } - if ($findings.Count -gt 0) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data @($findings) - } else { + if ($findings.Count -eq 0) { Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Role-escalable groups alert: no role-escalation group paths found" -sev 'Information' } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data @($findings) } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Role-escalable groups alert failed: $($ErrorMessage.NormalizedError)" -sev 'Error' -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 index 4a38cd6c23c9c..9eda2c6c02e2e 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecDefaultsDisabled.ps1 @@ -25,10 +25,10 @@ function Get-CIPPAlertSecDefaultsDisabled { Message = 'Security Defaults is disabled and no Conditional Access policies are configured. This tenant has no baseline security protection.' Tenant = $TenantFilter } - - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Security Defaults Disabled Alert: Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecDefaultsUpsell.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecDefaultsUpsell.ps1 index 6832a61a216ff..a595767d99ade 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecDefaultsUpsell.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecDefaultsUpsell.ps1 @@ -21,9 +21,9 @@ function Get-CIPPAlertSecDefaultsUpsell { Action = $SecDefaults.securityDefaultsUpsell.action Tenant = $TenantFilter } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecureScore.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecureScore.ps1 index 87ddec1e4ba2f..2a77108b19f85 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecureScore.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSecureScore.ps1 @@ -67,9 +67,7 @@ function Get-CippAlertSecureScore { } } - if ($SecureScoreResult) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $SecureScoreResult -PartitionKey SecureScore - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $SecureScoreResult } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Could not get Secure Score for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 index 3e0f6cd93532e..8cafb018d7cae 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSharepointQuota.ps1 @@ -38,7 +38,7 @@ function Get-CIPPAlertSharepointQuota { AlertQuotaThreshold = $Value Tenant = $TenantFilter } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 index a20c79d262a0c..2294ef8e9882d 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSmtpAuthSuccess.ps1 @@ -30,9 +30,7 @@ function Get-CIPPAlertSmtpAuthSuccess { $AlertData = $SignIns | Select-Object userPrincipalName, createdDateTime, clientAppUsed, ipAddress, status, @{Name = 'Tenant'; Expression = { $TenantFilter } } # Write results into the alert pipeline - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { # Suppress errors if no data returned diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 index e9db0b4df8c96..cb6d7ce2ab825 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertSoftDeletedMailboxes.ps1 @@ -21,9 +21,7 @@ function Get-CIPPAlertSoftDeletedMailboxes { $AlertData = $SoftDeletedMailBoxes | Where-Object { $_.IsInactiveMailbox -ne $true } # Write the alert trace with the filtered data - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 index 47868b4643668..afe1503f4e194 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertStaleEntraDevices.ps1 @@ -75,9 +75,7 @@ function Get-CIPPAlertStaleEntraDevices { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertTERRL.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertTERRL.ps1 index dcb8d932fcd1e..b0ee3cc449d1c 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertTERRL.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertTERRL.ps1 @@ -31,8 +31,8 @@ function Get-CIPPAlertTERRL { Message = 'Tenant is at {0}% of their TERRL limit (using {1} of {2} messages). Tenant Enforcement Status: {3}' -f $UsagePercentage, $TerrlStatus.ObservedValue, $TerrlStatus.Threshold, $TerrlStatus.Verdict Tenant = $TenantFilter } - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } } catch { $ErrorMessage = Get-CippException -Exception $_ diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertTenantAccess.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertTenantAccess.ps1 index 6b1e2f317e8d6..a393737c0101d 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertTenantAccess.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertTenantAccess.ps1 @@ -129,9 +129,7 @@ function Get-CIPPAlertTenantAccess { }) } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Tenant access alert error for $($TenantFilter): $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 index 751440ba7682f..325b48441c043 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnlicensedOneDriveData.ps1 @@ -58,6 +58,8 @@ function Get-CIPPAlertUnlicensedOneDriveData { } if ($Billing.UnlicensedOdbSyntexBillingEnabled -eq $true) { + # Billing is enabled, so unlicensed OneDrive data is retained: nothing can be pending deletion. + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $null return } @@ -161,7 +163,5 @@ function Get-CIPPAlertUnlicensedOneDriveData { $Item } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 index 98f958da3a88d..1441a0dff70dc 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUnusedLicenses.ps1 @@ -26,9 +26,7 @@ function Get-CIPPAlertUnusedLicenses { } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ Write-LogMessage -API 'Alerts' -tenant $TenantFilter -message "Unused Licenses Alert Error occurred: $($ErrorMessage.NormalizedError)" -sev Error -LogData $ErrorMessage diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUserReportedPhishing.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUserReportedPhishing.ps1 index 8a9a680f8401b..9978ede813744 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUserReportedPhishing.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertUserReportedPhishing.ps1 @@ -36,9 +36,7 @@ function Get-CIPPAlertUserReportedPhishing { Tenant = $TenantFilter } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { $ErrorMessage = Get-CippException -Exception $_ if ($ErrorMessage.NormalizedError -match 'dataservice\.protection\.outlook\.com' -or $ErrorMessage.NormalizedError -match 'No HTTP resource was found') { diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 index c2d228ad4b8bf..f09f11600e838 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVppTokenExpiry.ps1 @@ -32,9 +32,7 @@ function Get-CIPPAlertVppTokenExpiry { $Vpp | Select-Object -Property organizationName, appleId, vppTokenAccountType, @{Name = 'Message'; Expression = { $Message } }, @{Name = 'Tenant'; Expression = { $TenantFilter } } } } - if ($AlertData) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch {} diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 index 83dd5cce9310b..6d7ac3966aa0d 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CIPPAlertVulnerabilities.ps1 @@ -43,16 +43,28 @@ function Get-CIPPAlertVulnerabilities { } try { - $VulnerabilityGroups = New-GraphGetRequest -tenantid $TenantFilter -uri 'https://api.securitycenter.microsoft.com/api/machines/SoftwareVulnerabilitiesByMachine' -scope 'https://api.securitycenter.microsoft.com/.default' | - Where-Object { $_.cveId } | - Select-Object cveId, vulnerabilitySeverityLevel, firstSeenTimestamp, lastSeenTimestamp, cvssScore, exploitabilityLevel, softwareName, softwareVendor, softwareVersion, recommendedSecurityUpdate, recommendedSecurityUpdateId, recommendedSecurityUpdateUrl, deviceName, deviceId, osPlatform, osVersion, osArchitecture | - Group-Object cveId + # Fold the streamed export into one small bucket per CVE (earliest record, record count, + # unique device names) instead of grouping every device x CVE record in memory. + $Buckets = @{} + Get-DefenderTvmRaw -TenantId $TenantFilter -Stream | ForEach-Object { + if (-not $_.cveId) { return } + $Bucket = $Buckets[$_.cveId] + if (-not $Bucket) { + $Bucket = @{ First = $_; Count = 0; Devices = [System.Collections.Generic.List[string]]::new(); Seen = [System.Collections.Generic.HashSet[string]]::new() } + $Buckets[$_.cveId] = $Bucket + } elseif ($_.firstSeenTimestamp -lt $Bucket.First.firstSeenTimestamp) { + $Bucket.First = $_ + } + $Bucket.Count++ + if ($Bucket.Seen.Add([string]$_.deviceName)) { $Bucket.Devices.Add($_.deviceName) } + } - if ($VulnerabilityGroups) { + if ($Buckets.Count -gt 0) { $AlertData = [System.Collections.Generic.List[PSCustomObject]]::new() - foreach ($Group in $VulnerabilityGroups) { - $FirstVuln = $Group.Group | Sort-Object firstSeenTimestamp | Select-Object -First 1 + foreach ($CveId in ($Buckets.Keys | Sort-Object)) { + $Bucket = $Buckets[$CveId] + $FirstVuln = $Bucket.First $HoursOld = [math]::Round(((Get-Date) - [datetime]$FirstVuln.firstSeenTimestamp).TotalHours) # Skip based on age threshold mode @@ -82,16 +94,16 @@ function Get-CIPPAlertVulnerabilities { } $DaysOld = [math]::Round(((Get-Date) - [datetime]$FirstVuln.firstSeenTimestamp).TotalDays) - $AffectedDevices = ($Group.Group | Select-Object -ExpandProperty deviceName -Unique) -join ', ' + $AffectedDevices = $Bucket.Devices -join ', ' $VulnerabilityAlert = [PSCustomObject]@{ - CVE = $Group.Name + CVE = $CveId Severity = $FirstVuln.vulnerabilitySeverityLevel FirstSeenTimestamp = $FirstVuln.firstSeenTimestamp LastSeenTimestamp = $FirstVuln.lastSeenTimestamp DaysOld = $DaysOld HoursOld = $HoursOld - AffectedDeviceCount = $Group.Count + AffectedDeviceCount = $Bucket.Count SoftwareName = $FirstVuln.softwareName SoftwareVendor = $FirstVuln.softwareVendor SoftwareVersion = $FirstVuln.softwareVersion @@ -106,11 +118,9 @@ function Get-CIPPAlertVulnerabilities { $AlertData.Add($VulnerabilityAlert) } - # Only send alert if we have vulnerabilities that meet the criteria - if ($AlertData.Count -gt 0) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData - } } + + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $AlertData } catch { Write-LogMessage -message "Failed to check vulnerabilities: $($_.exception.message)" -API 'Vulnerability Alerts' -tenant $TenantFilter -sev Error } diff --git a/Modules/CIPPAlerts/Public/Alerts/Get-CippAlertBreachAlert.ps1 b/Modules/CIPPAlerts/Public/Alerts/Get-CippAlertBreachAlert.ps1 index b10f905a8fcb0..25bed1524cd45 100644 --- a/Modules/CIPPAlerts/Public/Alerts/Get-CippAlertBreachAlert.ps1 +++ b/Modules/CIPPAlerts/Public/Alerts/Get-CippAlertBreachAlert.ps1 @@ -12,9 +12,7 @@ function Get-CippAlertBreachAlert { ) try { $Search = New-BreachTenantSearch -TenantFilter $TenantFilter - if ($Search) { - Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $Search -PartitionKey BreachAlert - } + Write-AlertTrace -cmdletName $MyInvocation.MyCommand -tenantFilter $TenantFilter -data $Search } catch { $ErrorMessage = Get-CippException -Exception $_ Write-AlertMessage -tenant $($TenantFilter) -message "Could not get New Breaches for $($TenantFilter): $($ErrorMessage.NormalizedError)" diff --git a/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 b/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 deleted file mode 100644 index 0f119b13f266b..0000000000000 --- a/Modules/CIPPCore/Public/Add-CIPPBPAField.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -function Add-CIPPBPAField { - param ( - $BPAName = 'CIPP Standards v1.0 - Table view', - $FieldName, - $FieldValue, - $StoreAs, - $Tenant - ) - $Table = Get-CippTable -tablename 'cachebpav2' - $TenantName = Get-Tenants -TenantFilter $Tenant - $CurrentContentsObject = (Get-CIPPAzDataTableEntity @Table -Filter "RowKey eq '$BPAName' and PartitionKey eq '$($TenantName.customerId)'") - Write-Information "Adding $FieldName to $BPAName for $Tenant. content is $FieldValue" - if ($CurrentContentsObject.RowKey) { - $CurrentContents = @{} - $CurrentContentsObject.PSObject.Properties.Name | ForEach-Object { - $CurrentContents[$_] = $CurrentContentsObject.$_ - } - $Result = $CurrentContents - } else { - $Result = @{ - Tenant = "$($TenantName.displayName)" - GUID = "$($TenantName.customerId)" - RowKey = $BPAName - PartitionKey = "$($TenantName.customerId)" - LastRefresh = [string]$(Get-Date (Get-Date).ToUniversalTime() -UFormat '+%Y-%m-%dT%H:%M:%S.000Z') - } - } - switch -Wildcard ($StoreAs) { - '*bool' { - $Result["$fieldName"] = [bool]$FieldValue - } - 'JSON' { - if ($null -eq $FieldValue) { $JsonString = '{}' } else { $JsonString = (ConvertTo-Json -Depth 15 -InputObject $FieldValue -Compress) } - $Result[$fieldName] = [string]$JsonString - } - 'string' { - $Result[$fieldName] = [string]$FieldValue - } - } - Add-CIPPAzDataTableEntity @Table -Entity $Result -Force -} diff --git a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 index 597a479adb257..53cd4d1343b4a 100644 --- a/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPDbItem.ps1 @@ -57,6 +57,52 @@ function Add-CIPPDbItem { $RunStartUtc = [DateTimeOffset]::UtcNow.AddMinutes(-$SkewMarginMinutes) $TotalProcessed = 0 + + # The collection's shape - its fields and their types, one nested level deep - sampled from the + # first rows written and stored on the '-Count' row (Shape), so the report builder can + # offer a collection's fields without reading the collection. Sampled, not exhaustive: a field + # only some rows carry may be missed, which the picker tolerates by accepting a typed name. + $ShapeFields = [ordered]@{} + $ShapeSampled = 0 + $ShapeSampleSize = 50 + $ShapeMaxFields = 300 + $TypeOf = { + param($Value) + if ($null -eq $Value) { return 'null' } + if ($Value -is [bool]) { return 'boolean' } + if ($Value -is [datetime] -or $Value -is [DateTimeOffset]) { return 'date' } + if ($Value -is [string]) { return $(if ($Value -match '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}') { 'date' } else { 'string' }) } + if ($Value.GetType().IsPrimitive -or $Value -is [decimal]) { return 'number' } + if ($Value -is [System.Collections.IDictionary] -or $Value -is [System.Management.Automation.PSCustomObject]) { return 'object' } + if ($Value -is [System.Collections.IEnumerable]) { return 'array' } + 'string' + } + $PropertiesOf = { + param($Value) + if ($Value -is [System.Collections.IDictionary]) { foreach ($Key in $Value.Keys) { @{ Name = "$Key"; Value = $Value[$Key] } } } + elseif ($Value -is [System.Management.Automation.PSCustomObject]) { foreach ($Property in $Value.PSObject.Properties) { @{ Name = $Property.Name; Value = $Property.Value } } } + } + $NoteField = { + param([string]$Name, $Value) + if ($ShapeFields.Count -ge $ShapeMaxFields -and -not $ShapeFields.Contains($Name)) { return } + $Kind = & $TypeOf $Value + if (-not $ShapeFields.Contains($Name) -or $ShapeFields[$Name] -eq 'null') { $ShapeFields[$Name] = $Kind } + $Kind + } + $NoteShape = { + param($Item) + if ($ShapeSampled -ge $ShapeSampleSize) { return } + $ShapeSampled++ + foreach ($Property in @(& $PropertiesOf $Item)) { + $Kind = & $NoteField $Property.Name $Property.Value + # one level in: an object's own fields, or the fields of an array's first object + $Inner = if ($Kind -eq 'object') { $Property.Value } elseif ($Kind -eq 'array') { @($Property.Value | Select-Object -First 1)[0] } + if ($null -ne $Inner -and (& $TypeOf $Inner) -eq 'object') { + foreach ($Child in @(& $PropertiesOf $Inner)) { $null = & $NoteField "$($Property.Name).$($Child.Name)" $Child.Value } + } + } + } + # Cache regex instances so each row pays only the match cost, not regex compilation. # Two passes preserve the original semantics: path/wildcard chars → '_', control chars → stripped. $RowKeyPathRegex = [regex]::new('[/\\#?]') @@ -83,6 +129,7 @@ function Add-CIPPDbItem { $ItemId = $Item.ExternalDirectoryObjectId ?? $Item.id ?? $Item.Identity ?? $Item.skuId ?? $Item.userPrincipalName ?? [guid]::NewGuid().ToString() $RowKey = $RowKeyControlRegex.Replace($RowKeyPathRegex.Replace("$Type-$ItemId", '_'), '') if ($SeenInBatch.Add($RowKey)) { + & $NoteShape $Item $Batch.Add(@{ PartitionKey = $TenantFilter RowKey = $RowKey @@ -154,16 +201,32 @@ function Add-CIPPDbItem { if ($Count.IsPresent -or $AddCount.IsPresent) { $NewCount = $TotalProcessed - if ($Append.IsPresent) { + # The existing count row is read when appending (to add to its count) and when this call + # sampled no rows (a count-only call after the rows went in separately), so a shape + # already recorded is kept rather than wiped. + $ExistingCount = $null + if ($Append.IsPresent -or $ShapeFields.Count -eq 0) { $Filter = "PartitionKey eq '{0}' and RowKey eq '{1}-Count'" -f $TenantFilter, $Type $ExistingCount = Get-CIPPAzDataTableEntity @Table -Filter $Filter - if ($ExistingCount.DataCount) { $NewCount += [int]$ExistingCount.DataCount } } + if ($Append.IsPresent -and $ExistingCount.DataCount) { $NewCount += [int]$ExistingCount.DataCount } + if (($Append.IsPresent -or $ShapeFields.Count -eq 0) -and $ExistingCount.Shape) { + try { + foreach ($Known in @((ConvertFrom-Json -InputObject "$($ExistingCount.Shape)").fields)) { + if ($Known.name -and -not $ShapeFields.Contains([string]$Known.name)) { $ShapeFields[[string]$Known.name] = [string]$Known.type } + } + } catch { Write-Verbose "Unreadable shape on $Type-Count; recording afresh." } + } + $ShapeJson = ConvertTo-Json -InputObject @{ + fields = @(foreach ($Name in $ShapeFields.Keys) { @{ name = $Name; type = $ShapeFields[$Name] } }) + sampledAt = (Get-Date).ToUniversalTime().ToString('o') + } -Depth 5 -Compress $null = Add-CIPPAzDataTableEntity @Table -Entity @{ PartitionKey = $TenantFilter RowKey = "$Type-Count" DataCount = [int]$NewCount Type = $Type + Shape = $ShapeJson } -Force } diff --git a/Modules/CIPPCore/Public/Add-CIPPImage.ps1 b/Modules/CIPPCore/Public/Add-CIPPImage.ps1 index 2168999eb7642..804fb7581f1ab 100644 --- a/Modules/CIPPCore/Public/Add-CIPPImage.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPImage.ps1 @@ -28,8 +28,17 @@ function Add-CIPPImage { if ($Data -notmatch '^data:image\/([^;]+);base64,') { throw 'Invalid image format. Expected a data URL image (data:image/...;base64,...).' } + $Subtype = $Matches[1].ToLowerInvariant() - $ContentType = "image/$($Matches[1])" + # The formats the report engine draws (ReportComponents.NormaliseImage: rasters handed to OfficeIMO + # as-is, SVG rasterised once). Anything else would be stored, sent down on every page load, and then + # dropped at render time. The branding page enforces the same list before uploading. + $SupportedTypes = @('png', 'jpeg', 'jpg', 'gif', 'bmp', 'tiff', 'webp', 'svg+xml') + if ($Subtype -notin $SupportedTypes) { + throw "Unsupported image format 'image/$Subtype'. Use PNG, JPEG, GIF, BMP, TIFF, WebP or SVG." + } + + $ContentType = "image/$Subtype" $Base64Data = $Data -replace '^data:image\/[^;]+;base64,', '' try { $ImageBytes = [Convert]::FromBase64String($Base64Data) diff --git a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 index f94ec6b7675e7..8adbf96f73e00 100644 --- a/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 +++ b/Modules/CIPPCore/Public/Add-CIPPScheduledTask.ps1 @@ -27,6 +27,9 @@ function Add-CIPPScheduledTask { ) try { + # The [pscustomobject] parameter type doesn't convert hashtables, and PSObject.Properties can't see hashtable keys + if ($Task -is [System.Collections.IDictionary]) { $Task = [pscustomobject]$Task } + if ($Task.Parameters -is [System.Collections.IDictionary]) { $Task.Parameters = [pscustomobject]$Task.Parameters } $Table = Get-CIPPTable -TableName 'ScheduledTasks' diff --git a/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 b/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 index 4162a966d2d4e..a1c3c0a8243da 100644 --- a/Modules/CIPPCore/Public/AsyncDeployment/Get-CIPPAsyncDeployment.ps1 +++ b/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/Modules/CIPPCore/Public/AuditLogs/Resolve-CIPPAuditActor.ps1 b/Modules/CIPPCore/Public/AuditLogs/Resolve-CIPPAuditActor.ps1 new file mode 100644 index 0000000000000..4b3c640b4efa1 --- /dev/null +++ b/Modules/CIPPCore/Public/AuditLogs/Resolve-CIPPAuditActor.ps1 @@ -0,0 +1,71 @@ +function Resolve-CIPPAuditActor { + <# + .SYNOPSIS + Classifies the actor named in an audit record: the tenant's own user, a partner (GDAP) identity, CIPP itself, or an application. + .DESCRIPTION + Customer-tenant audit records name a partner identity in two shapes - user_@.onmicrosoft.com + (Entra) and .onmicrosoft.com\tenant: , object: (Exchange) - the same + shapes the audit-log pipeline maps back to partner users. This resolves them against the partner user + lookup, tells this partner tenant from another partner by tenant id, recognises CIPP's own SAM application + by app id, and returns the kind with the resolved name, so a case can show partner and CIPP actions as + such instead of as an unknown actor. + .PARAMETER Actor + The actor as written in the record: UserId, UserKey, or the initiating user principal name. + .PARAMETER ActorType + 'User' or 'Application' when the record says which (Entra directory audits do). Defaults to User. + .PARAMETER AppId + The application (client) id when the record was app-initiated. + .PARAMETER PartnerUserLookup + Hashtable of partner-tenant users keyed by object id (Get-CIPPPartnerUserLookup). Without it a partner + identity is still recognised by its shape, just not named. + .OUTPUTS + [pscustomobject] { Kind = User | Partner | OtherPartner | CIPP | Application | System; Actor; PartnerTenantId } + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [string]$Actor, + [string]$ActorType = 'User', + [string]$AppId, + [hashtable]$PartnerUserLookup = @{} + ) + $Raw = [string]$Actor + $Answer = { param($Kind, $Name, $TenantId) [pscustomobject]@{ Kind = $Kind; Actor = $Name; PartnerTenantId = $TenantId } } + $NameOf = { param($Id, $Fallback) if ($Id -and $PartnerUserLookup.ContainsKey($Id)) { [string]$PartnerUserLookup[$Id].userPrincipalName } else { $Fallback } } + $CippAppId = [string]$env:ApplicationID + $CippTenantId = [string]$env:TenantID + + if ($CippAppId -and (($AppId -and $AppId -eq $CippAppId) -or $Raw -eq $CippAppId)) { + return & $Answer 'CIPP' 'CIPP (service principal)' $CippTenantId + } + if ($ActorType -eq 'Application') { + return & $Answer 'Application' ($(if ($Raw) { $Raw } else { $AppId })) $null + } + if ([string]::IsNullOrWhiteSpace($Raw)) { + return & $Answer 'User' $Raw $null + } + if ($Raw -match '(?i)^NT AUTHORITY\\SYSTEM|^S-1-5-18$|Microsoft\.Exchange\.ServiceHost') { + return & $Answer 'System' $Raw $null + } + # Entra shape: user_<32 hex>@.onmicrosoft.com - the hex is the partner-tenant object id + if ($Raw -match '(?i)user_([0-9a-f]{32})@[^@]+\.onmicrosoft\.com') { + $Hex = $Matches[1].ToLowerInvariant() + $Id = "$($Hex.Substring(0, 8))-$($Hex.Substring(8, 4))-$($Hex.Substring(12, 4))-$($Hex.Substring(16, 4))-$($Hex.Substring(20, 12))" + # Known to this partner, or unknowable (no lookup): this partner. Known lookup, unknown id: another one. + $Kind = if ($PartnerUserLookup.Count -eq 0 -or $PartnerUserLookup.ContainsKey($Id)) { 'Partner' } else { 'OtherPartner' } + return & $Answer $Kind (& $NameOf $Id $Raw) $null + } + # Exchange shape: .onmicrosoft.com\tenant: , object: + if ($Raw -match '(?i)\\tenant:\s*([0-9a-f-]{36}),\s*object:\s*([0-9a-f-]{36})') { + $TenantId = $Matches[1] + $Id = $Matches[2] + $Kind = if ($CippTenantId -and $TenantId -eq $CippTenantId) { 'Partner' } else { 'OtherPartner' } + return & $Answer $Kind (& $NameOf $Id $Raw) $TenantId + } + # A bare object id as the actor is a service principal acting app-only + if ($Raw -match '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { + return & $Answer 'Application' $Raw $null + } + return & $Answer 'User' $Raw $null +} diff --git a/Modules/CIPPCore/Public/Authentication/ConvertTo-CIPPIPRange.ps1 b/Modules/CIPPCore/Public/Authentication/ConvertTo-CIPPIPRange.ps1 new file mode 100644 index 0000000000000..beb7fd1928664 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/ConvertTo-CIPPIPRange.ps1 @@ -0,0 +1,36 @@ +function ConvertTo-CIPPIPRange { + <# + .SYNOPSIS + Validates and normalises an IP address or CIDR range. + .DESCRIPTION + Accepts "203.0.113.10", "203.0.113.0/24", "2001:db8::1" or "2001:db8::/48" (surrounding + whitespace and IPv6 brackets are ignored) and returns the canonical text form: the address + as .NET prints it, plus "/prefix" when a prefix narrower than a single host is given. Throws + on anything that is not an IP address or a valid prefix length for its family. + .PARAMETER Value + The address or range. + .EXAMPLE + ConvertTo-CIPPIPRange -Value ' 203.0.113.0/24 ' # 203.0.113.0/24 + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([Parameter(Mandatory = $true)][AllowEmptyString()][string]$Value) + + $Text = $Value.Trim() -replace '[\[\]]', '' + if (-not $Text) { throw 'An IP address or range is required' } + $Parts = $Text -split '/' + if ($Parts.Count -gt 2) { throw "'$Value' is not a valid IP address or CIDR range" } + $Address = $null + if (-not [System.Net.IPAddress]::TryParse($Parts[0], [ref]$Address) -or $Parts[0] -notmatch '[.:]') { + throw "'$Value' is not a valid IP address or CIDR range" + } + $MaxBits = if ($Address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) { 128 } else { 32 } + if ($Parts.Count -eq 1) { return $Address.ToString() } + $Prefix = 0 + if (-not [int]::TryParse($Parts[1], [ref]$Prefix) -or $Prefix -lt 0 -or $Prefix -gt $MaxBits) { + throw "'$Value' has an invalid prefix length (0-$MaxBits)" + } + if ($Prefix -eq $MaxBits) { return $Address.ToString() } + return "$($Address.ToString())/$Prefix" +} diff --git a/Modules/CIPPCore/Public/Authentication/Get-CIPPIPAllowBlockList.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CIPPIPAllowBlockList.ps1 new file mode 100644 index 0000000000000..666516a732c67 --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Get-CIPPIPAllowBlockList.ps1 @@ -0,0 +1,37 @@ +function Get-CIPPIPAllowBlockList { + <# + .SYNOPSIS + Reads CIPP's IP allow/block list for a tenant. + .DESCRIPTION + The trustedIps table (managed from the Geo IP page) holds single addresses or CIDR ranges per + tenant (PartitionKey = default domain) or for every tenant (PartitionKey = AllTenants), each + Trusted or Blocked (NotTrusted rows are neutral and skipped). A CIDR range cannot be a table key, + so its RowKey carries '_' for '/' and the range itself is in the Range property; rows from before + ranges existed are a bare address in RowKey. Returns one entry per active row: + { Range, State (Trusted|Blocked), Scope (Tenant|AllTenants), Prefix, Note }. + Match an address against the result with Resolve-CIPPIPAllowBlockList. + .PARAMETER TenantFilter + The tenant's default domain name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([Parameter(Mandatory = $true)][string]$TenantFilter) + + $Table = Get-CIPPTable -TableName 'trustedIps' + $SafeTenant = $TenantFilter -replace "'", "''" + $Rows = Get-CIPPAzDataTableEntity @Table -Filter "(PartitionKey eq '$SafeTenant' or PartitionKey eq 'AllTenants') and (state eq 'Trusted' or state eq 'Blocked')" + @(foreach ($Row in @($Rows | Where-Object { $_ })) { + $Raw = if ($Row.Range) { [string]$Row.Range } else { ([string]$Row.RowKey) -replace '_', '/' } + $Range = try { ConvertTo-CIPPIPRange -Value $Raw } catch { $null } + if (-not $Range) { continue } + $Prefix = if ($Range -match '/(\d+)$') { [int]$Matches[1] } elseif ($Range -match ':') { 128 } else { 32 } + [pscustomobject]@{ + Range = $Range + State = [string]$Row.state + Scope = if ($Row.PartitionKey -eq 'AllTenants') { 'AllTenants' } else { 'Tenant' } + Prefix = $Prefix + Note = [string]$Row.Note + } + }) +} diff --git a/Modules/CIPPCore/Public/Authentication/Get-CippMcpKnownClients.ps1 b/Modules/CIPPCore/Public/Authentication/Get-CippMcpKnownClients.ps1 index bf7ee05cb861e..245155747ff63 100644 --- a/Modules/CIPPCore/Public/Authentication/Get-CippMcpKnownClients.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Get-CippMcpKnownClients.ps1 @@ -45,6 +45,9 @@ function Get-CippMcpKnownClients { 'https://insiders.vscode.dev/redirect' # Loopback for desktop/CLI clients (port-agnostic in Entra) 'http://127.0.0.1' + # Common local-dev default. NOTE: unlike 127.0.0.1, 'localhost' is only port-agnostic + # without a path, so this exact string only matches clients that use it verbatim. + 'http://localhost/callback' ) ConfidentialRedirectUris = @( # Copilot Studio / M365 Copilot agents (Power Platform connector redirect) diff --git a/Modules/CIPPCore/Public/Authentication/Grant-CippAppGraphConsent.ps1 b/Modules/CIPPCore/Public/Authentication/Grant-CippAppGraphConsent.ps1 index fb9198a70d561..191ef95ab4440 100644 --- a/Modules/CIPPCore/Public/Authentication/Grant-CippAppGraphConsent.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Grant-CippAppGraphConsent.ps1 @@ -2,7 +2,7 @@ function Grant-CippAppGraphConsent { <# .SYNOPSIS Ensures a tenant-wide (AllPrincipals) oauth2PermissionGrant exists from an app's service - principal to Microsoft Graph, covering the given delegated scopes. + principal to a resource API (Microsoft Graph by default), covering the given delegated scopes. .DESCRIPTION Admin-consents delegated Microsoft Graph scopes for an app registration in the partner tenant, so users signing in through it are not prompted to consent. Used to pre-consent @@ -20,8 +20,12 @@ function Grant-CippAppGraphConsent { .PARAMETER AppId Application (client) ID of the app whose service principal should receive the grant. .PARAMETER Scopes - Delegated Microsoft Graph scope names to ensure are consented (e.g. openid, profile, - offline_access). + Delegated scope names to ensure are consented (e.g. openid, profile, offline_access for + Graph, or user_impersonation for a custom resource). + .PARAMETER ResourceAppId + Application (client) ID of the resource whose delegated scopes are being consented. Defaults + to Microsoft Graph. Pass a custom API's app id to admin-consent, for example, an MCP public + client (SSO app) on the MCP resource app's user_impersonation scope. .FUNCTIONALITY Internal #> @@ -31,10 +35,11 @@ function Grant-CippAppGraphConsent { [string]$AppId, [Parameter(Mandatory)] - [string[]]$Scopes - ) + [string[]]$Scopes, - $GraphAppId = '00000003-0000-0000-c000-000000000000' + [Parameter()] + [string]$ResourceAppId = '00000003-0000-0000-c000-000000000000' + ) # The app's own service principal may still be replicating right after creation. $ClientSp = $null @@ -50,13 +55,23 @@ function Grant-CippAppGraphConsent { throw "Service principal for app '$AppId' was not found; cannot write consent grant yet." } - $GraphSp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$GraphAppId')?`$select=id" -NoAuthCheck $true -asapp $true - if (-not $GraphSp.id) { - throw 'Microsoft Graph service principal was not found in this tenant.' + # The resource's service principal may also still be replicating - e.g. right after the dedicated + # CIPP-MCP resource app is created, this consent runs in the same flow. Retry before giving up. + $ResourceSp = $null + for ($Attempt = 1; $Attempt -le 3 -and -not $ResourceSp.id; $Attempt++) { + try { + $ResourceSp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$ResourceAppId')?`$select=id" -NoAuthCheck $true -asapp $true + } catch { + Write-Information "[App-Consent] Resource service principal for $ResourceAppId not queryable yet (attempt $Attempt): $($_.Exception.Message)" + } + if (-not $ResourceSp.id -and $Attempt -lt 3) { Start-Sleep -Seconds 2 } + } + if (-not $ResourceSp.id) { + throw "Resource service principal for app '$ResourceAppId' was not found in this tenant." } $Grants = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals/$($ClientSp.id)/oauth2PermissionGrants" -NoAuthCheck $true -asapp $true) - $TenantGrant = $Grants | Where-Object { $_.resourceId -eq $GraphSp.id -and $_.consentType -eq 'AllPrincipals' } | Select-Object -First 1 + $TenantGrant = $Grants | Where-Object { $_.resourceId -eq $ResourceSp.id -and $_.consentType -eq 'AllPrincipals' } | Select-Object -First 1 if ($TenantGrant) { $CurrentScopes = @($TenantGrant.scope -split ' ' | Where-Object { $_ }) @@ -73,7 +88,7 @@ function Grant-CippAppGraphConsent { $CreateBody = @{ clientId = $ClientSp.id consentType = 'AllPrincipals' - resourceId = $GraphSp.id + resourceId = $ResourceSp.id scope = ($Scopes -join ' ') } | ConvertTo-Json -Compress $null = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/oauth2PermissionGrants' -body $CreateBody -type POST -NoAuthCheck $true -asapp $true diff --git a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 index 56aef6d4aab95..e4fb4e6ff7579 100644 --- a/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Initialize-CIPPAuth.ps1 @@ -251,6 +251,18 @@ function Initialize-CIPPAuth { # created before this existed, or whose service principal had not replicated at # creation, self-heals on the next warmup. Idempotent and cheap: the grant helper # no-ops once the scopes are present. Best-effort per client. + # Resolve the dedicated CIPP-MCP resource app so we can also reconcile the + # client -> resource user_impersonation consent (it can fail on the first Save if + # the freshly created resource SP hasn't replicated yet). + $McpResourceObjectId = $null + $McpResourceScopeId = $null + try { + $McpResRow = Get-CIPPAzDataTableEntity @(Get-CippTable -tablename 'CippMcpResource') -Filter "PartitionKey eq 'McpResource' and RowKey eq 'McpResource'" + if (-not [string]::IsNullOrWhiteSpace($McpResRow.ObjectId)) { $McpResourceObjectId = "$($McpResRow.ObjectId)" } + if (-not [string]::IsNullOrWhiteSpace($McpResRow.ScopeId)) { $McpResourceScopeId = "$($McpResRow.ScopeId)" } + } catch { + Write-Information "[Auth-Init] Could not resolve CIPP-MCP resource app for consent reconcile: $_" + } foreach ($McpId in $McpClientIds) { if ([string]::IsNullOrEmpty($McpId)) { continue } try { @@ -258,9 +270,47 @@ function Initialize-CIPPAuth { if ($McpConsent.Action -ne 'nochange') { Write-Information "[Auth-Init] MCP client $McpId offline_access consent: $($McpConsent.Action)" } + if ($McpResourceObjectId -and $McpResourceScopeId) { + $PreAuthChanged = Set-CippMcpResourcePreAuth -ResourceObjectId $McpResourceObjectId -ClientAppId $McpId -ScopeId $McpResourceScopeId + if ($PreAuthChanged) { + Write-Information "[Auth-Init] MCP client $McpId pre-authorized on CIPP-MCP resource user_impersonation scope" + } + } } catch { - Write-Information "[Auth-Init] MCP client $McpId offline_access consent reconcile failed (non-fatal): $_" + Write-Information "[Auth-Init] MCP client $McpId consent reconcile failed (non-fatal): $_" + } + } + + # Self-heal the split-app MCP wiring. If the dedicated CIPP-MCP resource app has + # never been provisioned, or its stored app registration no longer resolves in + # Entra, re-run Set-CIPPMCPClientApp for each MCPAllowed client — that ensures the + # resource app (New-CIPPMcpResourceApp) and configures the client (callbacks, + # permissions, consent). Gated so a healthy instance does no writes; covers + # instances enabled before the split. Best-effort. + $McpNeedsReconcile = $true + try { + $McpResTable = Get-CippTable -tablename 'CippMcpResource' + $McpResRow = Get-CIPPAzDataTableEntity @McpResTable -Filter "PartitionKey eq 'McpResource' and RowKey eq 'McpResource'" + if (-not [string]::IsNullOrWhiteSpace($McpResRow.AppId)) { + $McpResExists = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($McpResRow.AppId)')?`$select=appId" -NoAuthCheck $true -AsApp $true + $McpNeedsReconcile = -not [bool]$McpResExists.appId } + } catch { + Write-Information "[Auth-Init] Could not check CIPP-MCP resource state for reconcile (non-fatal): $_" + $McpNeedsReconcile = $false + } + if ($McpNeedsReconcile) { + foreach ($McpId in $McpClientIds) { + if ([string]::IsNullOrEmpty($McpId)) { continue } + try { + $null = Set-CIPPMCPClientApp -AppId $McpId -Headers @{} + Write-Information "[Auth-Init] MCP client $McpId + CIPP-MCP resource reconciled during warmup" + } catch { + Write-Information "[Auth-Init] MCP reconcile failed for client $McpId (non-fatal): $_" + } + } + } else { + Write-Information '[Auth-Init] CIPP-MCP resource app present — no MCP app reconcile needed' } # Ensure the MCP OAuth scope advertisement (challenge header + discovery docs) diff --git a/Modules/CIPPCore/Public/Authentication/New-CIPPMcpResourceApp.ps1 b/Modules/CIPPCore/Public/Authentication/New-CIPPMcpResourceApp.ps1 new file mode 100644 index 0000000000000..631d3500b07fa --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/New-CIPPMcpResourceApp.ps1 @@ -0,0 +1,220 @@ +function New-CIPPMcpResourceApp { + <# + .SYNOPSIS + Ensures the dedicated, CIPP-managed CIPP-MCP resource app registration exists. + .DESCRIPTION + In the MCP OAuth flow the *resource* app is the token audience and the identity the MCP + endpoint is exposed as - it holds the host identifier URIs (https:// and + https:///api/ExecMcp), exposes the user_impersonation scope, and is what EasyAuth + validates incoming tokens against. It is deliberately NOT an OAuth client: it has no redirect + URIs, no secret and no public-client flow. The apps that AI connectors sign in AS are the + MCPAllowed API clients (separate app registrations, each with its own role, IP range, + redirect URIs and Conditional Access) - keeping client and resource separate is what stops + the non-interactive refresh being "a token for itself" (AADSTS90009) and lets an MSP apply + device-compliance CA to the client apps without affecting the resource. + + Idempotent and self-healing. It resolves the resource app in this order: the appId stored in + the CippMcpResource table; an existing CIPP-MCP app that already owns this instance's host + URI (bind); an existing empty CIPP-MCP app left by a previous failed create (reuse, so we + don't spawn duplicates); otherwise create one. Before adding the host URIs it makes sure no + other app holds them: if the holder is one of CIPP's own MCP API clients (a single-app + leftover) it frees the URIs from it automatically; if it's a foreign app it records the + conflict (table + logbook) and throws so the pages can flag it. Persists + appId/objectId/scopeId to the table and returns @{ AppId; ObjectId; ScopeId }. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess)] + param($Headers) + + $Hostname = $env:WEBSITE_HOSTNAME + if ([string]::IsNullOrWhiteSpace($Hostname)) { + throw 'WEBSITE_HOSTNAME is not set; cannot determine the MCP resource URL.' + } + $DisplayName = 'CIPP-MCP' + $McpEndpointUri = "https://$Hostname/api/ExecMcp" + $HostUris = @("https://$Hostname", $McpEndpointUri) + $Table = Get-CippTable -tablename 'CippMcpResource' + + # 1) Stored appId is authoritative. + $Stored = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'McpResource' and RowKey eq 'McpResource'" + $App = $null + if ($Stored.AppId) { + try { + $App = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($Stored.AppId)')" -NoAuthCheck $true -AsApp $true + } catch { + Write-Information "[MCP-Resource] Stored CIPP-MCP appId $($Stored.AppId) no longer resolves; will rediscover/recreate. $($_.Exception.Message)" + } + } + + # 2) Bind to an existing CIPP-MCP app that owns the host URI; else reuse an empty CIPP-MCP app + # left behind by a previous failed create (avoids spawning duplicates). Simple displayName + # filter (no advanced-query quirks), matched in PowerShell. + if (-not $App.id) { + $Candidates = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications?`$filter=displayName eq '$DisplayName'&`$select=id,appId,api,identifierUris" -NoAuthCheck $true -AsApp $true) + $App = $Candidates | Where-Object { @($_.identifierUris) -contains $McpEndpointUri } | Select-Object -First 1 + if (-not $App.id) { + $App = $Candidates | Where-Object { @($_.identifierUris).Count -eq 0 } | Select-Object -First 1 + if ($App.id) { Write-Information "[MCP-Resource] Reusing existing empty CIPP-MCP app ($($App.appId)) instead of creating a new one." } + } + } + + # 3) Before creating/claiming, make sure no OTHER app holds a host URI (checked here so a foreign + # conflict never leaves an orphan created behind it). Self-heal our own MCP API clients + # (single-app leftovers); record + warn on a foreign app. Skip URIs this app already owns. + foreach ($HostUri in $HostUris) { + if ($App -and (@($App.identifierUris) -contains $HostUri)) { continue } + $Holder = $null + try { + $Holders = @(New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications?`$filter=identifierUris/any(u:u eq '$HostUri')&`$count=true&`$select=id,appId,displayName" -NoAuthCheck $true -AsApp $true -ComplexFilter) + $Holder = $Holders | Where-Object { $_.appId -and $_.appId -ne $App.appId } | Select-Object -First 1 + } catch { + Write-Information "[MCP-Resource] Could not check for a host-URI holder ($HostUri): $($_.Exception.Message)" + } + if (-not $Holder) { continue } + + $OwnedClient = $null + try { + $ApiClientsTable = Get-CippTable -tablename 'ApiClients' + $OwnedClient = Get-CIPPAzDataTableEntity @ApiClientsTable -Filter "RowKey eq '$($Holder.appId)'" + } catch { + Write-Information "[MCP-Resource] Could not check whether $($Holder.appId) is a managed API client: $($_.Exception.Message)" + } + if ($OwnedClient.RowKey) { + $HolderApp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$($Holder.appId)')?`$select=id,identifierUris" -NoAuthCheck $true -AsApp $true + $Kept = @(@($HolderApp.identifierUris) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) -and $HostUris -notcontains $_ }) + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($HolderApp.id)" -type PATCH -body (@{ identifierUris = @($Kept) } | ConvertTo-Json -Compress) -NoAuthCheck $true -asapp $true + Write-LogMessage -headers $Headers -API 'McpResource' -message "Migrated MCP: freed the resource URI ($HostUri) from API client '$($Holder.displayName)' ($($Holder.appId)) so the dedicated CIPP-MCP resource app can own it." -Sev 'Info' + } else { + $ConflictMsg = "MCP setup blocked: the app registration '$($Holder.displayName)' ($($Holder.appId)) already uses the MCP resource URL ($HostUri) and is not a CIPP-managed API client. Delete that app registration in Entra, then run Save to Azure again." + Write-LogMessage -headers $Headers -API 'McpResource' -message $ConflictMsg -Sev 'Warning' + $null = Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = 'McpResource' + RowKey = 'Error' + Message = $ConflictMsg + ConflictAppId = "$($Holder.appId)" + ConflictAppName = "$($Holder.displayName)" + DetectedAt = (Get-Date).ToUniversalTime().ToString('o') + } -Force + throw $ConflictMsg + } + } + + # 4) Create only if nothing could be resolved or reused. + if (-not $App.id) { + $CreateBody = @{ + displayName = $DisplayName + signInAudience = 'AzureADMyOrg' + api = @{ + requestedAccessTokenVersion = 2 + oauth2PermissionScopes = @( + @{ + adminConsentDescription = 'Allow the application to access CIPP-API on behalf of the signed-in user.' + adminConsentDisplayName = 'Access CIPP-API' + id = [guid]::NewGuid().ToString() + isEnabled = $true + type = 'User' + userConsentDescription = 'Allow the application to access CIPP-API on your behalf.' + userConsentDisplayName = 'Access CIPP-API' + value = 'user_impersonation' + } + ) + } + } | ConvertTo-Json -Depth 10 -Compress + if ($PSCmdlet.ShouldProcess($DisplayName, 'Create MCP resource app')) { + $App = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/applications' -type POST -body $CreateBody -NoAuthCheck $true -asapp $true + Write-LogMessage -headers $Headers -API 'McpResource' -message "Created MCP resource app '$DisplayName' ($($App.appId))." -Sev 'Info' + } + } + if (-not $App.appId) { + throw 'MCP resource app could not be created or resolved.' + } + + # Desired scope + identifier URIs. + $ApiObj = if ($App.api) { $App.api | ConvertTo-Json -Depth 10 | ConvertFrom-Json -AsHashtable } else { @{} } + $ScopeChanged = $false + $Scopes = [System.Collections.Generic.List[object]]::new() + if ($ApiObj.oauth2PermissionScopes) { foreach ($S in $ApiObj.oauth2PermissionScopes) { $Scopes.Add($S) } } + $UserImpersonation = $Scopes | Where-Object { $_.value -eq 'user_impersonation' } | Select-Object -First 1 + if (-not $UserImpersonation) { + $UserImpersonation = @{ + adminConsentDescription = 'Allow the application to access CIPP-API on behalf of the signed-in user.' + adminConsentDisplayName = 'Access CIPP-API' + id = [guid]::NewGuid().ToString() + isEnabled = $true + type = 'User' + userConsentDescription = 'Allow the application to access CIPP-API on your behalf.' + userConsentDisplayName = 'Access CIPP-API' + value = 'user_impersonation' + } + $Scopes.Add($UserImpersonation) + $ScopeChanged = $true + } + $ScopeId = $UserImpersonation.id + $VersionChanged = ($ApiObj.requestedAccessTokenVersion -ne 2) + + $IdentifierUris = [System.Collections.Generic.List[string]]::new() + foreach ($Uri in @($App.identifierUris)) { if (-not [string]::IsNullOrWhiteSpace($Uri) -and $IdentifierUris -notcontains $Uri) { $IdentifierUris.Add($Uri) } } + $UriChanged = $false + foreach ($Uri in (@("api://$($App.appId)") + $HostUris)) { if ($IdentifierUris -notcontains $Uri) { $IdentifierUris.Add($Uri); $UriChanged = $true } } + + # 5) Reconcile the resource app (identifier URIs + v2 tokens + user_impersonation). Retry the + # "in use" case briefly - right after freeing a URI, Entra can take a few seconds to release it. + if ($ScopeChanged -or $VersionChanged -or $UriChanged) { + if ($PSCmdlet.ShouldProcess($DisplayName, 'Reconcile MCP resource app')) { + $ApiObj.requestedAccessTokenVersion = 2 + $ApiObj.oauth2PermissionScopes = @($Scopes) + $PatchBody = @{ identifierUris = @($IdentifierUris); api = $ApiObj } | ConvertTo-Json -Depth 10 -Compress + for ($Attempt = 1; $Attempt -le 5; $Attempt++) { + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($App.id)" -type PATCH -body $PatchBody -NoAuthCheck $true -asapp $true + Write-LogMessage -headers $Headers -API 'McpResource' -message "Reconciled MCP resource app '$DisplayName' ($($App.appId)): identifier URIs, v2 tokens, user_impersonation." -Sev 'Info' + break + } catch { + $ErrMsg = $_.Exception.Message + $InUse = $ErrMsg -match 'identifierUri' -or $ErrMsg -match 'already exists' -or $ErrMsg -match 'in use' + if ($InUse -and $Attempt -lt 5) { Start-Sleep -Seconds 3; continue } + if ($InUse) { + throw "The MCP resource host URIs (https://$Hostname ...) are still assigned to another app registration. If a non-CIPP app holds them, remove it in Entra, then run Save to Azure again. ($ErrMsg)" + } + throw + } + } + } + } + + # Ensure a service principal so tokens are issued for this resource. + $Sp = $null + for ($Attempt = 1; $Attempt -le 6 -and -not $Sp.id; $Attempt++) { + try { + $Sp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals(appId='$($App.appId)')?`$select=id" -NoAuthCheck $true -AsApp $true + } catch { $Sp = $null } + if (-not $Sp.id) { + if ($Attempt -eq 1) { + try { + $SpBody = @{ accountEnabled = $true; appId = $App.appId; displayName = $DisplayName; tags = @('WindowsAzureActiveDirectoryIntegratedApp') } | ConvertTo-Json -Compress + $Sp = New-GraphPOSTRequest -uri 'https://graph.microsoft.com/v1.0/servicePrincipals' -type POST -body $SpBody -NoAuthCheck $true -asapp $true + } catch { Write-Information "[MCP-Resource] Service principal create attempt failed: $($_.Exception.Message)" } + } + if (-not $Sp.id -and $Attempt -lt 6) { Start-Sleep -Seconds 3 } + } + } + + $null = Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = 'McpResource' + RowKey = 'McpResource' + AppId = "$($App.appId)" + ObjectId = "$($App.id)" + ScopeId = "$ScopeId" + } -Force + + # The resource app is wired up, so clear any stored conflict error. + try { + $ErrRow = Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'McpResource' and RowKey eq 'Error'" + if ($ErrRow.RowKey) { $null = Remove-CIPPAzDataTableEntity @Table -Entity $ErrRow -Force } + } catch { + Write-Information "[MCP-Resource] Could not clear stored MCP conflict error: $($_.Exception.Message)" + } + + return @{ AppId = "$($App.appId)"; ObjectId = "$($App.id)"; ScopeId = "$ScopeId" } +} diff --git a/Modules/CIPPCore/Public/Authentication/Resolve-CIPPIPAllowBlockList.ps1 b/Modules/CIPPCore/Public/Authentication/Resolve-CIPPIPAllowBlockList.ps1 new file mode 100644 index 0000000000000..bfea8e7e389ad --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Resolve-CIPPIPAllowBlockList.ps1 @@ -0,0 +1,29 @@ +function Resolve-CIPPIPAllowBlockList { + <# + .SYNOPSIS + Finds the CIPP IP allow/block list entry that decides an address. + .DESCRIPTION + Of the entries (from Get-CIPPIPAllowBlockList) whose range contains the address, the most + specific range wins; at equal specificity a tenant entry beats an AllTenants one. So an + AllTenants block can be relaxed for one tenant by a tenant allow of the same or a narrower + range, and a single blocked address inside a trusted office range stays blocked. + Returns the winning entry, or $null when nothing matches. + .PARAMETER IPAddress + The address to look up (a port or IPv6 brackets are ignored). + .PARAMETER Entries + The list entries. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][AllowEmptyString()][string]$IPAddress, + [AllowNull()][object[]]$Entries + ) + + $Address = ConvertTo-CIPPBecHostAddress -Address $IPAddress + if (-not $Address -or -not $Entries) { return $null } + @($Entries | Where-Object { $_ -and (Test-IpInRange -IPAddress $Address -Range $_.Range) } | + Sort-Object -Property @{ Expression = { [int]$_.Prefix }; Descending = $true }, @{ Expression = { $_.Scope -eq 'Tenant' }; Descending = $true }) | + Select-Object -First 1 +} diff --git a/Modules/CIPPCore/Public/Authentication/Set-CIPPMCPClientApp.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CIPPMCPClientApp.ps1 index 098ccf81bcdd6..47f8d79adeb52 100644 --- a/Modules/CIPPCore/Public/Authentication/Set-CIPPMCPClientApp.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Set-CIPPMCPClientApp.ps1 @@ -1,15 +1,23 @@ function Set-CIPPMCPClientApp { <# .SYNOPSIS - Configures an API client's app registration to act as the MCP OAuth resource. + Configures an API client app registration as an MCP OAuth client. .DESCRIPTION - Adds the host-based MCP identifier URIs, forces v2 tokens, ensures the - user_impersonation scope, and pre-registers the well-known MCP clients from - Get-CippMcpKnownClients: their callback URLs as redirect URIs, loopback redirects - for desktop/CLI clients, and pre-authorization for first-party clients (VS Code) - so no manual app registration changes are needed to connect a client. + In the split-app model an MCPAllowed API client is one of the apps an AI connector signs in + AS (the OAuth client), while the dedicated CIPP-MCP app (New-CIPPMcpResourceApp) is the + protected resource the token is for. Several MCPAllowed clients can coexist, each with its own + role, IP range, redirect URIs and Conditional Access; every one is in EasyAuth + allowedApplications and CIPP resolves the caller's role from its appId (azp), so keeping them + as distinct app registrations is what makes per-client permissions and CA work. + + This ensures the resource app exists, then configures THIS client: the known MCP client + callbacks (public for the PKCE clients, web for Copilot Studio), "allow public client flows", + the delegated permissions it needs (Microsoft Graph openid/profile/offline_access and the + resource's user_impersonation), and tenant-wide admin consent for them. It also strips the + host identifier URIs from the client if a previous (single-app) setup left them there, so the + dedicated resource app can own them. .PARAMETER AppId - Application (client) ID of the API client to configure. + Application (client) ID of the MCPAllowed API client to configure as an MCP OAuth client. .FUNCTIONALITY Internal #> @@ -24,167 +32,101 @@ function Set-CIPPMCPClientApp { if ([string]::IsNullOrWhiteSpace($Hostname)) { throw 'WEBSITE_HOSTNAME is not set; cannot determine the MCP resource URL.' } + $GraphResourceId = '00000003-0000-0000-c000-000000000000' + $OidcScopeIds = @('37f7f235-527c-4136-accd-4a02d197296e', '14dad69e-099b-42c9-810b-d002981feec1', '7427e0e9-2fba-42fe-b0c0-848c9e6a8182') + $HostUris = @("https://$Hostname", "https://$Hostname/api/ExecMcp") + $KnownClients = Get-CippMcpKnownClients - $McpUris = @("https://$Hostname", "https://$Hostname/api/ExecMcp") + # Ensure the dedicated CIPP-MCP resource app exists; we consent this client on its scope. + $Resource = New-CIPPMcpResourceApp -Headers $Headers + $ResourceAppId = $Resource.AppId + $ResourceObjectId = $Resource.ObjectId + $ScopeId = $Resource.ScopeId $App = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications(appId='$AppId')" -NoAuthCheck $true -AsApp $true if (-not $App) { throw "App registration with AppId '$AppId' was not found." } - # Merge identifier URIs, preserving existing (e.g. api://) + # Strip the host identifier URIs from the client (a previous single-app setup may have added + # them); they belong on the dedicated resource app now. Keep everything else (e.g. api://). $IdentifierUris = [System.Collections.Generic.List[string]]::new() foreach ($Uri in @($App.identifierUris)) { - if (-not [string]::IsNullOrWhiteSpace($Uri) -and $IdentifierUris -notcontains $Uri) { $IdentifierUris.Add($Uri) } - } - foreach ($Uri in $McpUris) { - if ($IdentifierUris -notcontains $Uri) { $IdentifierUris.Add($Uri) } - } - - # Preserve the existing api object; force v2 tokens; ensure a user_impersonation delegated scope - $Api = if ($App.api) { $App.api | ConvertTo-Json -Depth 10 | ConvertFrom-Json -AsHashtable } else { @{} } - $Api.requestedAccessTokenVersion = 2 - $Scopes = [System.Collections.Generic.List[object]]::new() - if ($Api.oauth2PermissionScopes) { - foreach ($Scope in $Api.oauth2PermissionScopes) { $Scopes.Add($Scope) } - } - if (-not ($Scopes | Where-Object { $_.value -eq 'user_impersonation' })) { - $Scopes.Add(@{ - adminConsentDescription = 'Allow the application to access CIPP-API on behalf of the signed-in user.' - adminConsentDisplayName = 'Access CIPP-API' - id = [guid]::NewGuid().ToString() - isEnabled = $true - type = 'User' - userConsentDescription = 'Allow the application to access CIPP-API on your behalf.' - userConsentDisplayName = 'Access CIPP-API' - value = 'user_impersonation' - }) - } - $Api.oauth2PermissionScopes = @($Scopes) - - $KnownClients = Get-CippMcpKnownClients - $UserImpersonationScope = $Scopes | Where-Object { $_.value -eq 'user_impersonation' } | Select-Object -First 1 - - # Pre-authorize first-party MCP clients (e.g. VS Code) on the user_impersonation scope so - # they can sign users in without a consent prompt. The other known clients (Claude, ChatGPT, - # Copilot Studio) use this app's own client ID and need their callback URLs registered instead. - $PreAuthorized = [System.Collections.Generic.List[object]]::new() - if ($Api.preAuthorizedApplications) { - foreach ($Entry in $Api.preAuthorizedApplications) { $PreAuthorized.Add($Entry) } - } - foreach ($KnownAppId in $KnownClients.PreAuthorizedClientIds) { - $Existing = $PreAuthorized | Where-Object { $_.appId -eq $KnownAppId } | Select-Object -First 1 - if ($Existing) { - if (@($Existing.delegatedPermissionIds) -notcontains $UserImpersonationScope.id) { - $PermissionIds = [System.Collections.Generic.List[string]]::new() - foreach ($Id in @($Existing.delegatedPermissionIds)) { - if (-not [string]::IsNullOrWhiteSpace($Id)) { $PermissionIds.Add($Id) } - } - $PermissionIds.Add($UserImpersonationScope.id) - $Existing.delegatedPermissionIds = @($PermissionIds) - } - } else { - $PreAuthorized.Add(@{ - appId = $KnownAppId - delegatedPermissionIds = @($UserImpersonationScope.id) - }) - } + if (-not [string]::IsNullOrWhiteSpace($Uri) -and $HostUris -notcontains $Uri -and $IdentifierUris -notcontains $Uri) { $IdentifierUris.Add($Uri) } } - $Api.preAuthorizedApplications = @($PreAuthorized) - # Register the callback URLs of known MCP clients under the platform each client's token - # exchange requires — see Get-CippMcpKnownClients for why the bucket decides success. Every - # list below is rebuilt from the live app so a URI that an earlier version filed under the - # wrong platform is moved rather than duplicated (Entra rejects the same URI twice). + # Redirect URIs: public (PKCE clients) and web (Copilot Studio confidential + the EasyAuth login + # callback), rebuilt from the live app so a URI filed under the wrong platform is moved, not + # duplicated. Nothing of ours belongs under 'spa'. $PublicRedirectUris = [System.Collections.Generic.List[string]]::new() - foreach ($Uri in @($App.publicClient.redirectUris)) { - if (-not [string]::IsNullOrWhiteSpace($Uri) -and $PublicRedirectUris -notcontains $Uri) { $PublicRedirectUris.Add($Uri) } - } - foreach ($Uri in $KnownClients.PublicClientRedirectUris) { - if ($PublicRedirectUris -notcontains $Uri) { $PublicRedirectUris.Add($Uri) } - } + foreach ($Uri in @($App.publicClient.redirectUris)) { if (-not [string]::IsNullOrWhiteSpace($Uri) -and $PublicRedirectUris -notcontains $Uri) { $PublicRedirectUris.Add($Uri) } } + foreach ($Uri in $KnownClients.PublicClientRedirectUris) { if ($PublicRedirectUris -notcontains $Uri) { $PublicRedirectUris.Add($Uri) } } - # Web keeps the app's own confidential callbacks — the EasyAuth login callback above all — - # plus the secret-authenticating clients, minus anything now claimed by another platform. $WebRedirectUris = [System.Collections.Generic.List[string]]::new() foreach ($Uri in @($App.web.redirectUris)) { - if ([string]::IsNullOrWhiteSpace($Uri) -or $WebRedirectUris -contains $Uri) { continue } - if ($PublicRedirectUris -contains $Uri) { continue } + if ([string]::IsNullOrWhiteSpace($Uri) -or $WebRedirectUris -contains $Uri -or $PublicRedirectUris -contains $Uri) { continue } $WebRedirectUris.Add($Uri) } - foreach ($Uri in $KnownClients.ConfidentialRedirectUris) { - if ($WebRedirectUris -notcontains $Uri -and $PublicRedirectUris -notcontains $Uri) { $WebRedirectUris.Add($Uri) } - } + foreach ($Uri in $KnownClients.ConfidentialRedirectUris) { if ($WebRedirectUris -notcontains $Uri -and $PublicRedirectUris -notcontains $Uri) { $WebRedirectUris.Add($Uri) } } - # Nothing of ours belongs under 'spa' (those tokens can only be redeemed cross-origin, which - # no MCP client does), so preserve any URI the tenant added there but drop ours. $SpaRedirectUris = [System.Collections.Generic.List[string]]::new() foreach ($Uri in @($App.spa.redirectUris)) { - if ([string]::IsNullOrWhiteSpace($Uri) -or $SpaRedirectUris -contains $Uri) { continue } - if ($PublicRedirectUris -contains $Uri -or $WebRedirectUris -contains $Uri) { continue } + if ([string]::IsNullOrWhiteSpace($Uri) -or $SpaRedirectUris -contains $Uri -or $PublicRedirectUris -contains $Uri -or $WebRedirectUris -contains $Uri) { continue } $SpaRedirectUris.Add($Uri) } - # Declare offline_access (Microsoft Graph, delegated) so Entra will issue a refresh token to - # MCP clients. Without it, Copilot Studio (Manual OAuth) and stricter discovery clients - # re-prompt for sign-in roughly every hour when the access token expires. Additive — every - # permission already on the app is preserved; only offline_access is added if missing. - $GraphResourceId = '00000003-0000-0000-c000-000000000000' - $OfflineAccessId = '7427e0e9-2fba-42fe-b0c0-848c9e6a8182' + # requiredResourceAccess: preserve existing, ensure Graph OIDC + offline_access, and add the + # resource app's user_impersonation (delegated) so this client can request a token for it. $RequiredResourceAccess = [System.Collections.Generic.List[object]]::new() - $GraphEntrySeen = $false - foreach ($Resource in @($App.requiredResourceAccess)) { - $ResourceAccess = [System.Collections.Generic.List[object]]::new() - foreach ($Access in @($Resource.resourceAccess)) { $ResourceAccess.Add(@{ id = $Access.id; type = $Access.type }) } - if ($Resource.resourceAppId -eq $GraphResourceId) { - $GraphEntrySeen = $true - if (-not ($ResourceAccess | Where-Object { $_.id -eq $OfflineAccessId })) { - $ResourceAccess.Add(@{ id = $OfflineAccessId; type = 'Scope' }) - } + $GraphSeen = $false + $ResourceSeen = $false + foreach ($Resource2 in @($App.requiredResourceAccess)) { + $Access = [System.Collections.Generic.List[object]]::new() + foreach ($A in @($Resource2.resourceAccess)) { $Access.Add(@{ id = $A.id; type = $A.type }) } + if ($Resource2.resourceAppId -eq $GraphResourceId) { + $GraphSeen = $true + foreach ($Id in $OidcScopeIds) { if (-not ($Access | Where-Object { $_.id -eq $Id })) { $Access.Add(@{ id = $Id; type = 'Scope' }) } } + } elseif ($Resource2.resourceAppId -eq $ResourceAppId) { + $ResourceSeen = $true + if ($ScopeId -and -not ($Access | Where-Object { $_.id -eq $ScopeId })) { $Access.Add(@{ id = $ScopeId; type = 'Scope' }) } } - $RequiredResourceAccess.Add(@{ resourceAppId = $Resource.resourceAppId; resourceAccess = @($ResourceAccess) }) + $RequiredResourceAccess.Add(@{ resourceAppId = $Resource2.resourceAppId; resourceAccess = @($Access) }) + } + if (-not $GraphSeen) { + $RequiredResourceAccess.Add(@{ resourceAppId = $GraphResourceId; resourceAccess = @($OidcScopeIds | ForEach-Object { @{ id = $_; type = 'Scope' } }) }) } - if (-not $GraphEntrySeen) { - $RequiredResourceAccess.Add(@{ resourceAppId = $GraphResourceId; resourceAccess = @(@{ id = $OfflineAccessId; type = 'Scope' }) }) + if (-not $ResourceSeen -and $ScopeId) { + $RequiredResourceAccess.Add(@{ resourceAppId = $ResourceAppId; resourceAccess = @(@{ id = $ScopeId; type = 'Scope' }) }) } $PatchBody = @{ identifierUris = @($IdentifierUris) - api = $Api web = @{ redirectUris = @($WebRedirectUris) } spa = @{ redirectUris = @($SpaRedirectUris) } publicClient = @{ redirectUris = @($PublicRedirectUris) } requiredResourceAccess = @($RequiredResourceAccess) - # "Allow public client flows" — required for the secret-less PKCE redemption every MCP - # client above performs. isFallbackPublicClient = $true } | ConvertTo-Json -Depth 10 -Compress - if ($PSCmdlet.ShouldProcess($AppId, 'Configure app registration for MCP')) { - try { - $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($App.id)" -type PATCH -body $PatchBody -NoAuthCheck $true -asapp $true - Write-LogMessage -headers $Headers -API 'ExecApiClient' -message "Configured app registration $AppId as MCP resource (identifier URIs, v2 tokens, known MCP client callbacks + pre-authorization)." -Sev 'Info' - - # Admin-consent the OIDC + offline_access delegated scopes for this app so Entra - # issues the refresh token without a per-user consent prompt. Copilot Studio uses - # Manual OAuth and never reads the challenge/discovery scope, so this app-registration - # consent — not WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES — is what makes its refresh work. - # Best-effort: the app still works without it (users may see a one-time prompt, or the - # grant is retried the next time the client is saved), so a failure here is non-fatal. - try { - $ConsentResult = Grant-CippAppGraphConsent -AppId $AppId -Scopes @('openid', 'profile', 'offline_access') - Write-Information "[MCP-Client] offline_access admin-consent for $AppId : $($ConsentResult.Action)" - } catch { - Write-LogMessage -headers $Headers -API 'ExecApiClient' -message "MCP client $AppId configured, but admin-consent for offline_access could not be written (refresh tokens may prompt on first use): $($_.Exception.Message)" -Sev 'Warning' - } + if ($PSCmdlet.ShouldProcess($AppId, 'Configure API client as MCP OAuth client')) { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$($App.id)" -type PATCH -body $PatchBody -NoAuthCheck $true -asapp $true + Write-LogMessage -headers $Headers -API 'ExecApiClient' -message "Configured API client $AppId as an MCP OAuth client (callbacks, public client flows, resource permissions) against resource $ResourceAppId." -Sev 'Info' - return @{ Success = $true; IdentifierUris = @($IdentifierUris); RedirectUris = @($PublicRedirectUris) } + # OIDC + offline_access on Graph are admin-consented tenant-wide (they can't be + # pre-authorized). The resource's user_impersonation is handled by pre-authorizing this + # client on the CIPP-MCP resource app instead of a consent grant - that needs no consent at + # all, so it works even where user consent to apps is disabled. Both best-effort / non-fatal. + try { + $null = Grant-CippAppGraphConsent -AppId $AppId -Scopes @('openid', 'profile', 'offline_access') + } catch { + Write-LogMessage -headers $Headers -API 'ExecApiClient' -message "Failed to admin-consent Graph openid/profile/offline_access for MCP client ${AppId}: $($_.Exception.Message)" -Sev 'Warning' + } + try { + if ($ScopeId -and $ResourceObjectId) { $null = Set-CippMcpResourcePreAuth -ResourceObjectId $ResourceObjectId -ClientAppId $AppId -ScopeId $ScopeId } } catch { - $ErrMsg = $_.Exception.Message - if ($ErrMsg -match 'identifierUri' -or $ErrMsg -match 'already exists' -or $ErrMsg -match 'in use') { - throw "The MCP resource URIs are already assigned to another application. Only one API client can be the MCP resource client. ($ErrMsg)" - } - throw + Write-LogMessage -headers $Headers -API 'ExecApiClient' -message "Failed to pre-authorize MCP client $AppId on the CIPP-MCP resource user_impersonation scope: $($_.Exception.Message)" -Sev 'Warning' } + + return @{ Success = $true; ClientAppId = $AppId; ResourceAppId = $ResourceAppId; RedirectUris = @($PublicRedirectUris) } } } diff --git a/Modules/CIPPCore/Public/Authentication/Set-CippApiAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CippApiAuth.ps1 index 148026e25ec05..0a4e85c2a1b3d 100644 --- a/Modules/CIPPCore/Public/Authentication/Set-CippApiAuth.ps1 +++ b/Modules/CIPPCore/Public/Authentication/Set-CippApiAuth.ps1 @@ -65,18 +65,27 @@ function Set-CippApiAuth { [void]$AllAudiences.Add("api://$id") } - # MCP resource clients also accept tokens whose audience is the host-based identifier URI or - # the bare appId (v2 tokens), so the Claude connector's token validates against EasyAuth. + # The dedicated CIPP-MCP resource app is the token audience for MCP calls, so add its + # identifier URIs + bare appId (v2 tokens). The MCPAllowed client apps are the token's azp + # and are already in allowedApplications via $ClientIds. if ($McpClientIds -and $env:WEBSITE_HOSTNAME) { [void]$AllAudiences.Add("https://$($env:WEBSITE_HOSTNAME)") [void]$AllAudiences.Add("https://$($env:WEBSITE_HOSTNAME)/api/ExecMcp") - foreach ($McpId in $McpClientIds) { - if (-not [string]::IsNullOrEmpty($McpId)) { [void]$AllAudiences.Add($McpId) } + try { + $McpResTable = Get-CippTable -tablename 'CippMcpResource' + $McpResRow = Get-CIPPAzDataTableEntity @McpResTable -Filter "PartitionKey eq 'McpResource' and RowKey eq 'McpResource'" + if (-not [string]::IsNullOrWhiteSpace($McpResRow.AppId)) { + [void]$AllAudiences.Add("api://$($McpResRow.AppId)") + [void]$AllAudiences.Add("$($McpResRow.AppId)") + } + } catch { + Write-Information "[ApiAuth] Could not resolve CIPP-MCP resource app id for allowedAudiences: $($_.Exception.Message)" } } # First-party MCP clients (e.g. VS Code) bring their own client ID, so the token's azp - # is theirs — EasyAuth's allowedApplications must include them when MCP is enabled. + # is theirs — EasyAuth's allowedApplications must include them when MCP is enabled. The + # MCPAllowed API clients are already in $AllAppIds via $ClientIds. if ($McpClientIds) { foreach ($KnownId in (Get-CippMcpKnownClients).PreAuthorizedClientIds) { [void]$AllAppIds.Add($KnownId) @@ -132,8 +141,15 @@ function Set-CippApiAuth { if ($McpClientIds -and $env:WEBSITE_HOSTNAME) { $AudienceList.Add("https://$($env:WEBSITE_HOSTNAME)") $AudienceList.Add("https://$($env:WEBSITE_HOSTNAME)/api/ExecMcp") - foreach ($McpId in $McpClientIds) { - if (-not [string]::IsNullOrEmpty($McpId)) { $AudienceList.Add($McpId) } + try { + $McpResTable = Get-CippTable -tablename 'CippMcpResource' + $McpResRow = Get-CIPPAzDataTableEntity @McpResTable -Filter "PartitionKey eq 'McpResource' and RowKey eq 'McpResource'" + if (-not [string]::IsNullOrWhiteSpace($McpResRow.AppId)) { + $AudienceList.Add("api://$($McpResRow.AppId)") + $AudienceList.Add("$($McpResRow.AppId)") + } + } catch { + Write-Information "[ApiAuth] Could not resolve CIPP-MCP resource app id for allowedAudiences: $($_.Exception.Message)" } } $AllowedAudiences = @($AudienceList) diff --git a/Modules/CIPPCore/Public/Authentication/Set-CippMcpResourcePreAuth.ps1 b/Modules/CIPPCore/Public/Authentication/Set-CippMcpResourcePreAuth.ps1 new file mode 100644 index 0000000000000..a2c0d8d35d9cf --- /dev/null +++ b/Modules/CIPPCore/Public/Authentication/Set-CippMcpResourcePreAuth.ps1 @@ -0,0 +1,57 @@ +function Set-CippMcpResourcePreAuth { + <# + .SYNOPSIS + Pre-authorizes an MCP client app on the CIPP-MCP resource's user_impersonation scope. + .DESCRIPTION + Adds the client's appId to the CIPP-MCP resource app's api.preAuthorizedApplications + (additive, idempotent). Pre-authorization is what makes the client -> resource + user_impersonation grant require NO consent at all: the resource app trusts the listed + client, so Entra issues the token without a user or admin consent prompt - which is the + only reliable path in tenants that disable user consent to applications (CIPP's own + OauthConsentLowSec standard recommends exactly that). + + This is preferred over an AllPrincipals oauth2PermissionGrant for the resource scope because + it is written with Application.ReadWrite.All (patching CIPP's own app registration), so it + does not depend on Directory.ReadWrite.All being consented, nor on the resource service + principal having replicated - both of which make a create-time consent grant racy. + + Graph delegated scopes (openid/profile/offline_access) cannot be pre-authorized this way and + still need Grant-CippAppGraphConsent. + .PARAMETER ResourceObjectId + Object ID of the CIPP-MCP resource app registration to patch. + .PARAMETER ClientAppId + Application (client) ID of the MCP client to pre-authorize. + .PARAMETER ScopeId + The id of the resource's user_impersonation oauth2PermissionScope. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [Parameter(Mandatory)][string]$ResourceObjectId, + [Parameter(Mandatory)][string]$ClientAppId, + [Parameter(Mandatory)][string]$ScopeId + ) + + $ResApp = New-GraphGetRequest -uri "https://graph.microsoft.com/v1.0/applications/$ResourceObjectId`?`$select=api" -NoAuthCheck $true -asapp $true + $ApiObj = if ($ResApp.api) { $ResApp.api | ConvertTo-Json -Depth 10 | ConvertFrom-Json -AsHashtable } else { @{} } + + $PreAuth = [System.Collections.Generic.List[object]]::new() + foreach ($P in @($ApiObj.preAuthorizedApplications)) { $PreAuth.Add($P) } + + $Existing = $PreAuth | Where-Object { $_.appId -eq $ClientAppId } | Select-Object -First 1 + if ($Existing) { + $Ids = @($Existing.delegatedPermissionIds) + if ($Ids -contains $ScopeId) { return $false } + $Existing.delegatedPermissionIds = @($Ids + $ScopeId | Select-Object -Unique) + } else { + $PreAuth.Add(@{ appId = $ClientAppId; delegatedPermissionIds = @($ScopeId) }) + } + + $ApiObj.preAuthorizedApplications = @($PreAuth) + if ($PSCmdlet.ShouldProcess($ClientAppId, 'Pre-authorize MCP client on resource user_impersonation scope')) { + $Body = @{ api = $ApiObj } | ConvertTo-Json -Depth 10 -Compress + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/applications/$ResourceObjectId" -type PATCH -body $Body -NoAuthCheck $true -asapp $true + } + return $true +} diff --git a/Modules/CIPPCore/Public/BEC/ConvertTo-CIPPBecHostAddress.ps1 b/Modules/CIPPCore/Public/BEC/ConvertTo-CIPPBecHostAddress.ps1 new file mode 100644 index 0000000000000..6d154f455bfe4 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/ConvertTo-CIPPBecHostAddress.ps1 @@ -0,0 +1,20 @@ +function ConvertTo-CIPPBecHostAddress { + <# + .SYNOPSIS + Strips the client port (and IPv6 brackets) from an audit-log address so one host correlates as one. + .DESCRIPTION + Unified-audit-log records carry the client as "203.0.113.10:51234" or "[2001:db8::1]:443", and + the port differs on every connection. The investigation correlates activity by host, so every + collector projects the address through this before storing it. Same normalisation the geo-IP + batch helper applies to its keys. + .PARAMETER Address + The raw client address. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param([string]$Address) + + if ([string]::IsNullOrWhiteSpace($Address)) { return $null } + return ([regex]::Replace($Address.Trim(), '^(?(?:\d{1,3}(?:\.\d{1,3}){3}|\[[0-9a-fA-F:]+\]|[0-9a-fA-F:]+))(?::\d+)?$', '${IP}') -replace '[\[\]]', '') +} diff --git a/Modules/CIPPCore/Public/BEC/ConvertTo-CIPPBecIPEvents.ps1 b/Modules/CIPPCore/Public/BEC/ConvertTo-CIPPBecIPEvents.ps1 new file mode 100644 index 0000000000000..28ee7598cc090 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/ConvertTo-CIPPBecIPEvents.ps1 @@ -0,0 +1,59 @@ +function ConvertTo-CIPPBecIPEvents { + <# + .SYNOPSIS + Flattens a BEC results payload into the per-address activity the IP verdicts are built from. + .DESCRIPTION + One { IP, Kind, Flagged, ActorKind, When, SessionIds } per audited action the investigated + account took. Tenant-wide sections (mailbox permission changes, transport rule changes, + directory audits) only contribute the rows this account made - another admin's address says + nothing about who holds this account. Flagged marks the actions an attacker takes: a suspicious + inbox rule, a permission change on this mailbox, a safelist change, an anonymous link, a risky + transport rule change, a flagged directory event (security info, consent, device), or mail + sent as part of a mass-mail pattern. + .PARAMETER Results + The BEC results payload (as assembled by Push-BECRun or read back from storage). + .PARAMETER UserPrincipalName + The investigated user. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)]$Results, + [Parameter(Mandatory = $true)][string]$UserPrincipalName + ) + + $IsUser = { param($Value) [string]$Value -and ([string]$Value).Trim() -ieq $UserPrincipalName } + $Leaf = { param($Value) (([string]$Value) -split '\\')[-1] } + $SuspiciousRules = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Rule in @($Results.NewRules | Where-Object { $_ -and $_.Suspicious -eq $true })) { $null = $SuspiciousRules.Add([string]$Rule.Name) } + $MassMail = $Results.SentMessageAnalysis.Flagged -eq $true + $New = { param($IP, $Kind, $Flagged, $ActorKind, $When, $SessionIds) [pscustomobject]@{ IP = $IP; Kind = $Kind; Flagged = [bool]$Flagged; ActorKind = $(if ($ActorKind) { [string]$ActorKind } else { 'User' }); When = $When; SessionIds = @($SessionIds | Where-Object { $_ }) } } + + @( + foreach ($Row in @($Results.InboxRuleChanges | Where-Object { $_.ClientIP })) { + & $New $Row.ClientIP 'Inbox rule change' ($SuspiciousRules.Contains((& $Leaf $Row.RuleName))) $Row.ActorKind $Row.Date + } + foreach ($Row in @($Results.MailboxPermissionChanges | Where-Object { $_.ClientIP -and ((& $IsUser $_.UserId) -or (& $IsUser $_.UserKey)) })) { + & $New $Row.ClientIP 'Mailbox permission change' $Row.TargetsSuspect $Row.ActorKind $Row.Date + } + foreach ($Row in @($Results.SafelistChanges | Where-Object { $_.ClientIP })) { + & $New $Row.ClientIP 'Safelist change' $true $Row.ActorKind $Row.Date + } + foreach ($Row in @($Results.SharingChanges | Where-Object { $_.ClientIP })) { + & $New $Row.ClientIP 'Sharing change' ([string]$Row.Operation -like 'AnonymousLink*') $Row.ActorKind $Row.Date + } + foreach ($Row in @($Results.TransportRuleChanges | Where-Object { $_.ClientIP -and (& $IsUser $_.Actor) })) { + & $New $Row.ClientIP 'Transport rule change' ($Row.Flagged -eq $true) $Row.ActorKind $Row.Date + } + foreach ($Row in @($Results.DirectoryAudits | Where-Object { $_.ClientIP -and (& $IsUser $_.InitiatedBy) })) { + & $New $Row.ClientIP 'Directory change' ($Row.Flagged -eq $true) $Row.ActorKind $Row.ActivityDateTime + } + foreach ($Row in @($Results.SentMessages | Where-Object { $_.FromIP })) { + & $New $Row.FromIP 'Sent mail' $MassMail 'User' $Row.Received + } + foreach ($Row in @($Results.MailActivity | Where-Object { $_.ClientIP })) { + & $New $Row.ClientIP "Mailbox $($Row.Operation)" $false $Row.ActorKind $Row.FirstSeen $Row.SessionIds + } + ) +} diff --git a/Modules/CIPPCore/Public/BEC/Disable-CIPPInboxRules.ps1 b/Modules/CIPPCore/Public/BEC/Disable-CIPPInboxRules.ps1 new file mode 100644 index 0000000000000..ea181f6f07748 --- /dev/null +++ b/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/Modules/CIPPCore/Public/BEC/Get-CIPPBecAttackerActivity.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecAttackerActivity.ps1 new file mode 100644 index 0000000000000..8cca4f2f12758 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecAttackerActivity.ps1 @@ -0,0 +1,351 @@ +function Get-CIPPBecAttackerActivity { + <# + .SYNOPSIS + Collects item-level detail of what the attacker's addresses did: mail opened, synced, deleted, + moved and sent, files touched, sharing links used, and Microsoft Forms built from the account. + .DESCRIPTION + Only the attacker side is kept in detail - addresses whose verdict is Compromised, + LikelyAttacker, Suspicious or Unknown - so the user's own day-to-day activity stays as the + counts it already is (MailActivity). + Every record is tied to an address, even when the audit log left it out or recorded a + Microsoft front-end address: its own client address, else the sign-in behind its token + (AppAccessContext.UniqueTokenId), else its Entra session (AppAccessContext.AADSessionId), + else another record of the same mailbox session. Of the candidates, the one with the worst + verdict wins - a record tied to an attacker token is the attacker's. + - Mail: from the mailbox records the MailActivity search already read (no second search): + one row per message opened (internet message id + folder, named from the message trace), + per folder synced by a desktop client (the whole folder counts as taken), per item deleted, + moved or sent, per attachment read, plus folder-permission, calendar-delegation, mailbox + setting and mailbox search events. + - Files: one SharePoint/OneDrive search filtered to the attacker-side addresses. + - Link usage: the anonymous, company and specific-people links created in the window, and who + opened them (these are logged against the opener, so they are searched by item). + - Forms: every Microsoft Forms action by the account with its verdict; for forms created, + edited, shared or sent from an attacker-side address, how many people opened or answered + them and whether Microsoft flagged them as phishing. + Returns { Mail, Files, LinkUsage, Forms, Subjects } where each is a collector result (Mail and + Forms carry a Summary). + .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 (attackerActivity section, caps). + .PARAMETER Verdicts + The IP verdict rows. + .PARAMETER SignIns + The window's interactive sign-ins (IPAddress, UniqueTokenId, SessionId). + .PARAMETER NonInteractiveSignIns + The window's non-interactive sign-ins. + .PARAMETER MailRecords + The raw mailbox audit records from Get-CIPPBecMailActivity. + .PARAMETER SharingChanges + The sharing-link changes of the window (ItemUrl / ObjectId). + .PARAMETER KnownSubjects + Subjects already known by internet message id (the run's sent and received traces). + .PARAMETER Anchor + Anchor mailbox for the Exchange 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, + [object[]]$Verdicts = @(), + [object[]]$SignIns = @(), + [object[]]$NonInteractiveSignIns = @(), + [object[]]$MailRecords = @(), + [object[]]$SharingChanges = @(), + [hashtable]$KnownSubjects = @{}, + [string]$Anchor + ) + + $Cfg = $Heuristics.attackerActivity + $MaxPages = [int]($Heuristics.caps.auditLogPages ?? 10) + $AttackerSide = @('Compromised', 'LikelyAttacker', 'Suspicious', 'Unknown') + $Rank = @{ Compromised = 0; LikelyAttacker = 1; Suspicious = 2; Unknown = 3; LikelyUser = 4; Safe = 5; Service = 6 } + $HostOf = { param($Value) ConvertTo-CIPPBecHostAddress -Address ([string]$Value) } + $VerdictOf = @{} + foreach ($Row in @($Verdicts | Where-Object { $_ -and $_.IP })) { $VerdictOf[[string]$Row.IP] = [string]$Row.Verdict } + # audit CreationTime is UTC without a zone: read it as UTC, not as the host's local time + $When = { param($Value) try { if ($Value) { $(if ($Value -is [datetime]) { if ($Value.Kind -eq 'Local') { $Value.ToUniversalTime() } else { [datetime]::SpecifyKind($Value, 'Utc') } } else { [datetime]::Parse([string]$Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]'AssumeUniversal,AdjustToUniversal') }).ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } } catch { [string]$Value } } + + # --- tie records to addresses: tokens and Entra sessions from the sign-ins, mailbox sessions from the records --- + $Tokens = @{} + $EntraSessions = @{} + foreach ($SignIn in @(@($SignIns) + @($NonInteractiveSignIns) | Where-Object { $_ })) { + $IP = & $HostOf $SignIn.IPAddress + if (-not $IP) { continue } + if ($SignIn.UniqueTokenId) { $Tokens[[string]$SignIn.UniqueTokenId] = $IP } + if ($SignIn.SessionId) { + if (-not $EntraSessions.ContainsKey([string]$SignIn.SessionId)) { $EntraSessions[[string]$SignIn.SessionId] = [System.Collections.Generic.HashSet[string]]::new() } + $null = $EntraSessions[[string]$SignIn.SessionId].Add($IP) + } + } + $MailboxSessions = @{} + foreach ($Record in @($MailRecords | Where-Object { $_ })) { + $AD = $Record.AuditData + $IP = & $HostOf ($AD.ClientIP ?? $AD.ClientIPAddress) + if (-not $IP -or -not $AD.SessionId) { continue } + if (-not $MailboxSessions.ContainsKey([string]$AD.SessionId)) { $MailboxSessions[[string]$AD.SessionId] = [System.Collections.Generic.HashSet[string]]::new() } + $null = $MailboxSessions[[string]$AD.SessionId].Add($IP) + } + $Worst = { + param($Candidates) + @($Candidates | Where-Object { $_.IP } | Sort-Object -Property @{ Expression = { $Rank[[string]$VerdictOf[$_.IP]] ?? 9 } }, @{ Expression = { $_.Order } }) | Select-Object -First 1 + } + $Resolve = { + param($AD) + $Context = $AD.AppAccessContext + $Candidates = [System.Collections.Generic.List[object]]::new() + $RecordIP = & $HostOf ($AD.ClientIP ?? $AD.ClientIPAddress) + if ($RecordIP) { $Candidates.Add([pscustomobject]@{ IP = $RecordIP; Source = 'record'; Order = 0 }) } + if ($Context.UniqueTokenId -and $Tokens.ContainsKey([string]$Context.UniqueTokenId)) { $Candidates.Add([pscustomobject]@{ IP = $Tokens[[string]$Context.UniqueTokenId]; Source = 'token'; Order = 1 }) } + if ($Context.AADSessionId -and $EntraSessions.ContainsKey([string]$Context.AADSessionId)) { + foreach ($IP in $EntraSessions[[string]$Context.AADSessionId]) { $Candidates.Add([pscustomobject]@{ IP = $IP; Source = 'Entra session'; Order = 2 }) } + } + if ($AD.SessionId -and $MailboxSessions.ContainsKey([string]$AD.SessionId)) { + foreach ($IP in $MailboxSessions[[string]$AD.SessionId]) { $Candidates.Add([pscustomobject]@{ IP = $IP; Source = 'mailbox session'; Order = 3 }) } + } + # a record whose own address is a Microsoft front end says nothing about the actor: prefer what it is tied to + $Useful = @($Candidates | Where-Object { $VerdictOf[$_.IP] -and $VerdictOf[$_.IP] -ne 'Service' }) + $Pick = if ($Useful.Count -gt 0) { & $Worst $Useful } else { $Candidates | Select-Object -First 1 } + if (-not $Pick) { return [pscustomobject]@{ IP = $null; Source = 'none'; Verdict = $null } } + [pscustomobject]@{ IP = $Pick.IP; Source = $Pick.Source; Verdict = $(if ($VerdictOf[$Pick.IP]) { $VerdictOf[$Pick.IP] } else { 'Unknown' }) } + } + $Props = { param($AD) @(@($AD.OperationProperties) + @($AD.Parameters) | Where-Object { $_ -and $_.Name } | ForEach-Object { "$($_.Name)=$($_.Value)" }) -join '; ' } + + # --- mail: item-level rows from the records already read --- + $MailRows = [System.Collections.Generic.List[object]]::new() + $Unattributed = 0 + foreach ($Record in @($MailRecords | Where-Object { $_ -and $_.AuditData })) { + $AD = $Record.AuditData + $Address = & $Resolve $AD + if (-not $Address.IP) { $Unattributed++; continue } + if ($Address.Verdict -notin $AttackerSide) { continue } + $Operation = [string]($AD.Operation ?? $Record.Operation) + $AccessType = [string]($AD.MailAccessType ?? (@($AD.OperationProperties) | Where-Object { $_.Name -eq 'MailAccessType' } | Select-Object -First 1).Value) + $Client = [string]($AD.ClientInfoString ?? $AD.AppAccessContext.ClientAppName) + if ($Client.Length -gt 120) { $Client = $Client.Substring(0, 120) + '...' } + $New = { + param($Folder, $Subject, $MessageId, $Detail, $ItemCount) + $MailRows.Add([pscustomobject]@{ + When = & $When $AD.CreationTime + Operation = $Operation + AccessType = $AccessType + Folder = [string]$Folder + Subject = [string]$Subject + InternetMessageId = [string]$MessageId + ItemCount = $ItemCount + Detail = [string]$Detail + IP = $Address.IP + IPSource = $Address.Source + IPVerdict = $Address.Verdict + MailboxOwner = [string]($AD.MailboxOwnerUPN ?? $UserPrincipalName) + LogonType = $AD.LogonType + Client = $Client + SessionId = [string]$AD.SessionId + }) + } + switch ($Operation) { + 'MailItemsAccessed' { + $Folders = @($AD.Folders | Where-Object { $_ }) + if ($Folders.Count -eq 0) { & $New $null $null $null (& $Props $AD) ([int]($AD.OperationCount ?? 1)); break } + foreach ($Folder in $Folders) { + $Items = @($Folder.FolderItems | Where-Object { $_ }) + if ($AccessType -eq 'Sync') { + & $New $Folder.Path '(whole folder synced to a desktop client)' $null $null $Items.Count + } else { + foreach ($Item in $Items) { & $New $Folder.Path $Item.Subject $Item.InternetMessageId $null 1 } + if ($Items.Count -eq 0) { & $New $Folder.Path $null $null $null 0 } + } + } + } + { $_ -in @('SoftDelete', 'HardDelete', 'MoveToDeletedItems', 'Move', 'Copy') } { + $Destination = if ($AD.DestFolder.Path) { "to $($AD.DestFolder.Path)" } else { $null } + $Items = @($AD.AffectedItems | Where-Object { $_ }) + foreach ($Item in $Items) { & $New $Item.ParentFolder.Path $Item.Subject $Item.InternetMessageId $Destination 1 } + if ($Items.Count -eq 0) { & $New $AD.Folder.Path $null $null $Destination 0 } + } + { $_ -in @('Send', 'SendAs', 'SendOnBehalf', 'AttachmentAccess') } { + $Item = $AD.Item + $Detail = if ($Item.Attachments) { "Attachments: $($Item.Attachments)" } else { & $Props $AD } + & $New $Item.ParentFolder.Path $Item.Subject $Item.InternetMessageId $Detail 1 + } + 'UpdateFolderPermissions' { + $Folder = $AD.Item.ParentFolder + & $New $Folder.Path $null $null "$($Folder.MemberUpn): $($Folder.MemberRights)" 1 + } + default { & $New $AD.Item.ParentFolder.Path $AD.Item.Subject $AD.Item.InternetMessageId (& $Props $AD) 1 } + } + } + + # name the opened messages that the records did not name + $SubjectResult = [pscustomobject]@{ Resolved = 0; Unresolved = 0; Error = $null } + $Unnamed = @($MailRows | Where-Object { $_.InternetMessageId -and -not $_.Subject } | ForEach-Object { $_.InternetMessageId } | Select-Object -Unique) + if ($Unnamed.Count -gt 0) { + $SubjectResult = Resolve-CIPPBecMessageSubjects -TenantFilter $TenantFilter -MessageIds $Unnamed -Known $KnownSubjects -LookbackDays ([int]($Cfg.subjectLookbackDays ?? 90)) -Anchor $Anchor + foreach ($Row in $MailRows) { + if ($Row.InternetMessageId -and -not $Row.Subject -and $SubjectResult.Subjects.ContainsKey($Row.InternetMessageId)) { $Row.Subject = $SubjectResult.Subjects[$Row.InternetMessageId] } + } + } + $Count = { param($Ops) @($MailRows | Where-Object { $_.Operation -in $Ops }).Count } + $MailSummary = [pscustomobject]@{ + Rows = $MailRows.Count + MessagesOpened = @($MailRows | Where-Object { $_.Operation -eq 'MailItemsAccessed' -and $_.InternetMessageId } | ForEach-Object { $_.InternetMessageId } | Select-Object -Unique).Count + FoldersSynced = @($MailRows | Where-Object { $_.Operation -eq 'MailItemsAccessed' -and $_.AccessType -eq 'Sync' } | ForEach-Object { "$($_.MailboxOwner)|$($_.Folder)" } | Select-Object -Unique).Count + Deleted = & $Count @('SoftDelete', 'HardDelete', 'MoveToDeletedItems') + Moved = & $Count @('Move') + Sent = & $Count @('Send', 'SendAs', 'SendOnBehalf') + AttachmentsRead = & $Count @('AttachmentAccess') + Searches = & $Count @('SearchQueryInitiatedExchange') + OtherMailboxes = @($MailRows | Where-Object { $_.MailboxOwner -and $_.MailboxOwner -ne $UserPrincipalName } | ForEach-Object { $_.MailboxOwner } | Select-Object -Unique).Count + Unattributed = $Unattributed + SubjectsResolved = [int]$SubjectResult.Resolved + SubjectsUnresolved = [int]$SubjectResult.Unresolved + } + $Mail = New-CIPPBecCollectorResult -Data @($MailRows | Sort-Object -Property When) -Error $SubjectResult.Error + $Mail | Add-Member -NotePropertyName 'Summary' -NotePropertyValue $MailSummary -Force + + # --- files: one search for the attacker-side addresses --- + $SideIPs = @($Verdicts | Where-Object { $_.Verdict -in $AttackerSide } | ForEach-Object { [string]$_.IP } | Select-Object -Unique) + $Files = if ($SideIPs.Count -eq 0) { + New-CIPPBecCollectorResult -Data @() + } else { + try { + $FileRows = [System.Collections.Generic.List[object]]::new() + $Complete = $true + $Cap = $null + for ($i = 0; $i -lt $SideIPs.Count; $i += 50) { + $Chunk = @($SideIPs[$i..([Math]::Min($i + 49, $SideIPs.Count - 1))]) + $Search = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $StartDate -EndDate $EndDate -Operations @($Cfg.fileOperations) -UserIds @($UserPrincipalName) -IPAddresses $Chunk -Anchor $Anchor -MaxPages $MaxPages + if (-not $Search.Complete) { $Complete = $false; $Cap = $Search.Cap } + foreach ($Record in @($Search.Records)) { + $AD = $Record.AuditData + if (-not $AD) { continue } + $Address = & $Resolve $AD + $FileRows.Add([pscustomobject]@{ + When = & $When $AD.CreationTime + Operation = [string]$AD.Operation + File = [string]$AD.SourceFileName + Url = [string]$AD.ObjectId + Site = [string]$AD.SiteUrl + Destination = if ($AD.DestinationFileName) { "$($AD.DestinationRelativeUrl)/$($AD.DestinationFileName)" } else { $null } + SearchQuery = [string]($AD.SearchQueryText ?? $AD.QueryText) + IP = $Address.IP + IPSource = $Address.Source + IPVerdict = $Address.Verdict + UserAgent = [string]$AD.UserAgent + App = [string]($AD.ApplicationDisplayName ?? $AD.AppAccessContext.ClientAppName) + }) + } + } + $Result = New-CIPPBecCollectorResult -Data @($FileRows | Sort-Object -Property When) -Complete $Complete -Cap $Cap + $Result | Add-Member -NotePropertyName 'Summary' -NotePropertyValue ([pscustomobject]@{ + Files = @($FileRows | ForEach-Object { $_.Url } | Where-Object { $_ } | Select-Object -Unique).Count + Downloaded = @($FileRows | Where-Object { $_.Operation -in @('FileDownloaded', 'FileSyncDownloadedFull') }).Count + Accessed = @($FileRows | Where-Object { $_.Operation -in @('FileAccessed', 'FilePreviewed') }).Count + Uploaded = @($FileRows | Where-Object { $_.Operation -eq 'FileUploaded' }).Count + Deleted = @($FileRows | Where-Object { $_.Operation -in @('FileDeleted', 'FileRecycled') }).Count + Searches = @($FileRows | Where-Object { $_.Operation -eq 'SearchQueryPerformed' }).Count + }) -Force + $Result + } catch { + New-CIPPBecCollectorResult -Data @() -Error "File activity search failed: $((Get-NormalizedError -message $_.Exception.Message))" + } + } + + # --- link usage: who opened the links created in the window --- + $LinkItems = @($SharingChanges | ForEach-Object { [string]($_.ItemUrl ?? $_.ObjectId) } | Where-Object { $_ -match '^https?://' } | Select-Object -Unique) + $LinkUsage = if ($LinkItems.Count -eq 0) { + New-CIPPBecCollectorResult -Data @() + } else { + try { + $UseRows = [System.Collections.Generic.List[object]]::new() + $Complete = $true + $Cap = $null + for ($i = 0; $i -lt $LinkItems.Count; $i += 50) { + $Chunk = @($LinkItems[$i..([Math]::Min($i + 49, $LinkItems.Count - 1))]) + $Search = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $StartDate -EndDate $EndDate -Operations @($Cfg.linkUsageOperations) -ObjectIds $Chunk -Anchor $Anchor -MaxPages $MaxPages + if (-not $Search.Complete) { $Complete = $false; $Cap = $Search.Cap } + foreach ($Record in @($Search.Records)) { + $AD = $Record.AuditData + if (-not $AD) { continue } + $UseRows.Add([pscustomobject]@{ + When = & $When $AD.CreationTime + Operation = [string]$AD.Operation + File = [string]$AD.SourceFileName + Url = [string]$AD.ObjectId + OpenedBy = [string]$AD.UserId + IP = & $HostOf ($AD.ClientIP ?? $AD.ClientIPAddress) + UserAgent = [string]$AD.UserAgent + }) + } + } + New-CIPPBecCollectorResult -Data @($UseRows | Sort-Object -Property When) -Complete $Complete -Cap $Cap + } catch { + New-CIPPBecCollectorResult -Data @() -Error "Sharing-link usage search failed: $((Get-NormalizedError -message $_.Exception.Message))" + } + } + + # --- Microsoft Forms: everything the account did, and the reach of forms built from attacker addresses --- + $Forms = try { + $Search = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $StartDate -EndDate $EndDate -RecordType 'MicrosoftForms' -UserIds @($UserPrincipalName) -Anchor $Anchor -MaxPages $MaxPages + $FormRows = @(foreach ($Record in @($Search.Records)) { + $AD = $Record.AuditData + if (-not $AD) { continue } + $Address = & $Resolve $AD + $Operation = [string]$AD.Operation + [pscustomobject]@{ + When = & $When $AD.CreationTime + Operation = $Operation + FormName = [string]$AD.FormName + FormId = [string]($AD.FormId ?? $AD.ObjectId) + UserType = [string]$AD.FormsUserType + IP = $Address.IP + IPSource = $Address.Source + IPVerdict = $Address.Verdict + Flagged = [bool]($Address.Verdict -in $AttackerSide -and $Operation -in @($Cfg.formsAttackerOperations)) + Detail = & $Props $AD + } + }) + $FormSummaries = @(foreach ($FormId in @($FormRows | Where-Object { $_.Flagged -and $_.FormId } | ForEach-Object { $_.FormId } | Select-Object -Unique)) { + $Reach = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $StartDate -EndDate $EndDate -Operations @($Cfg.formsResponseOperations) -FreeText $FormId -Anchor $Anchor -MaxPages $MaxPages + $Records = @($Reach.Records | Where-Object { $_.AuditData -and [string]($_.AuditData.FormId ?? $_.AuditData.ObjectId) -eq $FormId }) + $Responses = @($Records | Where-Object { $_.AuditData.Operation -in @('CreateResponse', 'SubmitResponse') }) + [pscustomobject]@{ + FormId = $FormId + FormName = ($FormRows | Where-Object { $_.FormId -eq $FormId -and $_.FormName } | Select-Object -First 1).FormName + Responses = $Responses.Count + AnonymousResponses = @($Responses | Where-Object { -not $_.AuditData.ResponderId }).Count + Views = @($Records | Where-Object { $_.AuditData.Operation -eq 'ViewRuntimeForm' }).Count + PhishingFlagged = [bool](@($Records | Where-Object { $_.AuditData.Operation -eq 'UpdatePhishingStatus' }).Count) + Complete = [bool]$Reach.Complete + } + }) + $Result = New-CIPPBecCollectorResult -Data @($FormRows | Sort-Object -Property When) -Complete ([bool]$Search.Complete) -Cap $Search.Cap + $Result | Add-Member -NotePropertyName 'Summary' -NotePropertyValue ([pscustomobject]@{ + FlaggedActions = @($FormRows | Where-Object { $_.Flagged }).Count + Forms = @($FormSummaries) + }) -Force + $Result + } catch { + New-CIPPBecCollectorResult -Data @() -Error "Microsoft Forms search failed: $((Get-NormalizedError -message $_.Exception.Message))" + } + + [pscustomobject]@{ + Mail = $Mail + Files = $Files + LinkUsage = $LinkUsage + Forms = $Forms + Subjects = $SubjectResult + } +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecBlastRadius.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecBlastRadius.ps1 new file mode 100644 index 0000000000000..58575169b0a2d --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecBlastRadius.ps1 @@ -0,0 +1,168 @@ +function Get-CIPPBecBlastRadius { + <# + .SYNOPSIS + Lists the other accounts in the tenant that the attacker addresses reached. + .DESCRIPTION + An attacker rarely stops at one account. For every address judged Compromised or + LikelyAttacker (never Suspicious or Unknown - a guess would send the investigator after + colleagues on the user's own office exit), it gathers: + - the tenant's sign-ins from the address (Get-CIPPBecIPPeers, reused from the IP analysis + when it already looked the address up), per account successful and failed; + - the tenant's audit log for the address across every workload, with no user filter, per + account the operations it recorded. + One row per other account: sign-ins, actions by operation, first and last seen. An account + with a successful sign-in or any recorded action from an attacker address is flagged as + reached; failed sign-ins alone are an attempt. Rows carry the account's object id so each + one can be investigated in turn. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The investigated user's object id (excluded). + .PARAMETER UserPrincipalName + The investigated user (excluded). + .PARAMETER Verdicts + The IP verdicts (Get-CIPPBecIPVerdicts). + .PARAMETER Peers + Hashtable keyed by IP from the IP analysis (Get-CIPPBecIPPeers shape). + .PARAMETER StartDate + Window start (UTC). + .PARAMETER EndDate + Window end (UTC). + .PARAMETER Heuristics + The BEC heuristics object (caps.auditLogPages). + .PARAMETER Anchor + Anchor mailbox for the EXO request. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$UserId, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [object[]]$Verdicts = @(), + [hashtable]$Peers = @{}, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$EndDate, + $Heuristics, + [string]$Anchor + ) + + $AttackerIPs = @($Verdicts | Where-Object { $_ -and $_.Verdict -in @('Compromised', 'LikelyAttacker') } | ForEach-Object { [string]$_.IP } | Select-Object -Unique) + if ($AttackerIPs.Count -eq 0) { return New-CIPPBecCollectorResult -Data @() } + + $MaxPages = [int]($Heuristics.caps.auditLogPages ?? 10) + $Errors = [System.Collections.Generic.List[string]]::new() + $Complete = $true + $Cap = $null + $IsUser = { param($Who, $Id) ($UserId -and $Id -and [string]$Id -eq $UserId) -or ([string]$Who -ieq $UserPrincipalName) } + $Accounts = @{} + $Touch = { + param($Who) + $Key = ([string]$Who).ToLowerInvariant() + if (-not $Accounts.ContainsKey($Key)) { + $Accounts[$Key] = [pscustomobject]@{ + UserPrincipalName = [string]$Who; UserId = $null; IPs = [System.Collections.Generic.HashSet[string]]::new() + Successful = 0; Failed = 0; Actions = 0; Operations = @{}; FirstSeen = $null; LastSeen = $null + } + } + $Accounts[$Key] + } + $Seen = { + param($Account, $Stamp) + if (-not $Stamp) { return } + if (-not $Account.FirstSeen -or $Stamp -lt $Account.FirstSeen) { $Account.FirstSeen = $Stamp } + if (-not $Account.LastSeen -or $Stamp -gt $Account.LastSeen) { $Account.LastSeen = $Stamp } + } + + # --- sign-ins: reuse the analysis' tenant-wide lookup, look up only what it did not --- + $Known = @{} + foreach ($IP in $AttackerIPs) { + $Peer = $Peers[$IP] + # an entry only from the colleague sample, or stored before accounts were kept, is looked up again + if ($Peer -and $Peer.PSObject.Properties['Accounts'] -and (@($Peer.Accounts).Count -gt 0 -or [int]$Peer.OtherUsers -eq 0)) { $Known[$IP] = $Peer } + } + $Missing = @($AttackerIPs | Where-Object { -not $Known.ContainsKey($_) }) + if ($Missing.Count -gt 0) { + try { + $Found = Get-CIPPBecIPPeers -TenantFilter $TenantFilter -UserId $(if ($UserId) { $UserId } else { 'none' }) -IPs $Missing -StartDate $StartDate -WindowStart $StartDate + foreach ($Key in $Found.Keys) { $Known[$Key] = $Found[$Key] } + } catch { + $Errors.Add("sign-ins: $($_.Exception.Message)") + } + } + foreach ($IP in $Known.Keys) { + if ($Known[$IP].Error) { $Errors.Add("sign-ins from $($IP): $($Known[$IP].Error)") } + foreach ($Entry in @($Known[$IP].Accounts | Where-Object { $_ -and $_.UserPrincipalName })) { + if (& $IsUser $Entry.UserPrincipalName $Entry.UserId) { continue } + $Account = & $Touch $Entry.UserPrincipalName + if ($Entry.UserId) { $Account.UserId = [string]$Entry.UserId } + $null = $Account.IPs.Add($IP) + $Account.Successful = $Account.Successful + [int]$Entry.Successful + $Account.Failed = $Account.Failed + [int]$Entry.Failed + & $Seen $Account $Entry.FirstSeen + & $Seen $Account $Entry.LastSeen + } + } + + # --- audit log: every workload, no user filter --- + try { + for ($i = 0; $i -lt $AttackerIPs.Count; $i += 50) { + $Chunk = @($AttackerIPs[$i..([Math]::Min($i + 49, $AttackerIPs.Count - 1))]) + $Search = Search-CIPPBecAuditLog -TenantFilter $TenantFilter -StartDate $StartDate -EndDate $EndDate -IPAddresses $Chunk -Anchor $Anchor -MaxPages $MaxPages + if (-not $Search.Complete) { $Complete = $false; $Cap = $Search.Cap } + foreach ($Record in @($Search.Records)) { + $AD = $Record.AuditData + $Who = [string]($AD.UserId ?? $Record.UserId) + # service principals and system accounts (app@sharepoint, NT AUTHORITY, S-1-5-...) are not accounts to investigate + if ($Who -notmatch '@' -or $Who -match '^app@sharepoint$' -or (& $IsUser $Who $null)) { continue } + $Account = & $Touch $Who + $Address = ConvertTo-CIPPBecHostAddress -Address ([string]($AD.ClientIP ?? $AD.ClientIPAddress ?? $AD.ActorIpAddress)) + if ($Address) { $null = $Account.IPs.Add($Address) } + $Account.Actions++ + $Operation = [string]($AD.Operation ?? $Record.Operation) + $Account.Operations[$Operation] = [int]($Account.Operations[$Operation] ?? 0) + 1 + # audit CreationTime is UTC without a zone: read it as UTC, not as the host's local time + $Value = $AD.CreationTime ?? $Record.CreationDate + $When = try { $(if ($Value -is [datetime]) { if ($Value.Kind -eq 'Local') { $Value.ToUniversalTime() } else { [datetime]::SpecifyKind($Value, 'Utc') } } else { [datetime]::Parse([string]$Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]'AssumeUniversal,AdjustToUniversal') }).ToString('yyyy-MM-ddTHH:mm:ssZ') } catch { $null } + & $Seen $Account $When + } + } + } catch { + $Errors.Add("audit log: $($_.Exception.Message)") + } + + # accounts seen only in the audit log carry a UPN, not an object id: resolve them so they can be investigated + $Unresolved = @($Accounts.Values | Where-Object { -not $_.UserId } | Select-Object -First 50) + if ($Unresolved.Count -gt 0) { + try { + $Requests = for ($i = 0; $i -lt $Unresolved.Count; $i++) { + @{ id = "u$i"; method = 'GET'; url = "users/$([uri]::EscapeDataString($Unresolved[$i].UserPrincipalName))?`$select=id" } + } + foreach ($Response in @(New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter -asapp $true)) { + if ([int]$Response.status -ge 400 -or -not $Response.body.id) { continue } + $Unresolved[[int]([string]$Response.id).Substring(1)].UserId = [string]$Response.body.id + } + } catch { + Write-Information "Blast radius: could not resolve account ids: $($_.Exception.Message)" + } + } + + $Rows = @($Accounts.Values | ForEach-Object { + $Reached = $_.Successful -gt 0 -or $_.Actions -gt 0 + [pscustomobject]@{ + UserPrincipalName = $_.UserPrincipalName + UserId = $_.UserId + Reached = $Reached + AttackerIPs = @($_.IPs | Sort-Object) -join ', ' + SuccessfulSignIns = $_.Successful + FailedSignIns = $_.Failed + Actions = $_.Actions + Operations = @($_.Operations.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { "$($_.Key) x$($_.Value)" }) -join ', ' + FirstSeen = $_.FirstSeen + LastSeen = $_.LastSeen + Flagged = $Reached + } + } | Sort-Object -Property @{ Expression = { $_.Reached }; Descending = $true }, @{ Expression = { $_.SuccessfulSignIns + $_.Actions }; Descending = $true }, UserPrincipalName) + New-CIPPBecCollectorResult -Data $Rows -Complete $Complete -Cap $Cap -Error $(if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null }) +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecColleagueSample.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecColleagueSample.ps1 new file mode 100644 index 0000000000000..8c364b536a6eb --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecColleagueSample.ps1 @@ -0,0 +1,40 @@ +function Get-CIPPBecColleagueSample { + <# + .SYNOPSIS + Picks a handful of recently active colleagues whose sign-ins to correlate with a BEC case. + .DESCRIPTION + The investigation cannot know on its own which addresses are shared offices, VPNs or a home + network used by several people, so it looks at who else is signing in: one page of the tenant's + recent successful interactive sign-ins, reduced to member accounts other than the investigated + user (guests and the CIPP service account excluded), from which Count are picked at random. + Their sign-ins are then read with Get-CIPPBecCorrelatedUserPeers. Returns the object ids. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER ExcludeUserId + The investigated user's object id. + .PARAMETER StartDate + Only sign-ins from this time (UTC). + .PARAMETER Count + How many colleagues to pick. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$ExcludeUserId, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [ValidateRange(1, 50)][int]$Count = 8 + ) + + $From = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Uri = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=createdDateTime ge $From and status/errorCode eq 0&`$top=999&`$select=userId,userPrincipalName,userType,appId" + $SignIns = @(New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true -noPagination $true) + $CippAppId = [string]$env:ApplicationID + $Candidates = @($SignIns | Where-Object { + $_.userId -and $_.userId -ne $ExcludeUserId -and $_.userType -ne 'guest' -and + (-not $CippAppId -or $_.appId -ne $CippAppId) -and [string]$_.userPrincipalName -notlike 'cipp*' + } | ForEach-Object { [string]$_.userId } | Select-Object -Unique) + if ($Candidates.Count -le $Count) { return $Candidates } + return @($Candidates | Get-Random -Count $Count) +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1 new file mode 100644 index 0000000000000..9b75a1ecd9925 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecContainmentActions.ps1 @@ -0,0 +1,54 @@ +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 + built-in set below, overridden by the instance-wide defaults saved from CIPP settings via + ExecBECRemediationDefaults), 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() + + $Catalog = @( + [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, ActiveSync and SMTP AUTH by default; OWA, MAPI and ECP optional).'; Impact = 'High'; Reversible = $true; DefaultSelected = $true; 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 = $false; 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' } + ) + + # Instance-wide defaults saved from CIPP settings replace the built-in DefaultSelected flags. + # A missing row or a failed read keeps the built-in set. + $Saved = try { + $Table = Get-CIPPTable -TableName Settings + (Get-CIPPAzDataTableEntity @Table -Filter "PartitionKey eq 'BecRemediation' and RowKey eq 'Defaults'").DefaultActions + } catch { $null } + if ($Saved) { + $SavedIds = @($Saved | ConvertFrom-Json) + foreach ($Action in $Catalog) { $Action.DefaultSelected = $Action.Id -in $SavedIds } + } + $Catalog +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecCorrelatedUserPeers.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecCorrelatedUserPeers.ps1 new file mode 100644 index 0000000000000..b80bc98607e3f --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecCorrelatedUserPeers.ps1 @@ -0,0 +1,83 @@ +function Get-CIPPBecCorrelatedUserPeers { + <# + .SYNOPSIS + Reads the sign-ins of accounts the investigator chose, to correlate the case's addresses with them. + .DESCRIPTION + For each chosen account, one Graph batch reads its interactive sign-ins (paged to the end) and + the first page of its non-interactive ones since the baseline start. Only the case's own + addresses matter: an address a colleague used before the window is evidence it is an office + or shared exit; one a colleague signed in from only during the window points at the attacker + reaching further. Returns a hashtable keyed by IP in the same shape as Get-CIPPBecIPPeers + ({ IP, OtherUsers, OtherUsersBefore, OtherUsersInWindowOnly, Users, Sampled, Error }), which + Invoke-CIPPBecIPAnalysis merges into the tenant-wide peers. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserIds + Object ids of the accounts to correlate. + .PARAMETER IPs + The case's addresses (host form). + .PARAMETER StartDate + How far back to read (UTC) - the baseline start. + .PARAMETER WindowStart + The start of the investigation window (UTC). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string[]]$UserIds = @(), + [string[]]$IPs = @(), + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$WindowStart + ) + + $Result = @{} + $Users = @($UserIds | Where-Object { $_ } | Select-Object -Unique) + $Wanted = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($IP in @($IPs | Where-Object { $_ })) { $null = $Wanted.Add([string]$IP) } + if ($Users.Count -eq 0 -or $Wanted.Count -eq 0) { return $Result } + + $From = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Select = 'ipAddress,userPrincipalName,userId,createdDateTime' + $Requests = [System.Collections.Generic.List[object]]::new() + $NoPaginate = [System.Collections.Generic.List[string]]::new() + for ($i = 0; $i -lt $Users.Count; $i++) { + $SafeId = ConvertTo-CIPPODataFilterValue -Value ([string]$Users[$i]) -Type Guid + $Filter = "userId eq '$SafeId' and createdDateTime ge $From" + $Requests.Add(@{ id = "i$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter&`$top=999&`$select=$Select" }) + $Requests.Add(@{ id = "n$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter and signInEventTypes/any(t: t eq 'nonInteractiveUser')&`$top=999&`$select=$Select" }) + $NoPaginate.Add("n$i") + } + $Responses = @(New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter -asapp $true -Version 'beta' -NoPaginateIds @($NoPaginate)) + + $Window = $WindowStart.ToUniversalTime() + $Before = @{} + $During = @{} + $Failed = @($Responses | Where-Object { [int]$_.status -ge 400 }) + foreach ($SignIn in @($Responses | Where-Object { [int]$_.status -lt 400 } | ForEach-Object { $_.body.value })) { + if (-not $SignIn) { continue } + $IP = ConvertTo-CIPPBecHostAddress -Address ([string]$SignIn.ipAddress) + if (-not $IP -or -not $Wanted.Contains($IP)) { continue } + $Who = if ($SignIn.userPrincipalName) { [string]$SignIn.userPrincipalName } else { [string]$SignIn.userId } + $When = try { ([datetime]$SignIn.createdDateTime).ToUniversalTime() } catch { $null } + $Bucket = if ($When -and $When -lt $Window) { $Before } else { $During } + if (-not $Bucket.ContainsKey($IP)) { $Bucket[$IP] = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) } + $null = $Bucket[$IP].Add($Who) + } + foreach ($IP in @(@($Before.Keys) + @($During.Keys) | Select-Object -Unique)) { + $Earlier = if ($Before.ContainsKey($IP)) { @($Before[$IP]) } else { @() } + $Later = if ($During.ContainsKey($IP)) { @($During[$IP] | Where-Object { $_ -notin $Earlier }) } else { @() } + $All = @(@($Earlier) + @($Later) | Select-Object -Unique) + $Result[$IP] = [pscustomobject]@{ + IP = $IP + OtherUsers = $All.Count + OtherUsersBefore = $Earlier.Count + OtherUsersInWindowOnly = $Later.Count + Users = @($All | Sort-Object) + Sampled = $true + Error = if ($Failed.Count -gt 0) { [string]$Failed[0].body.error.message } else { $null } + } + } + return $Result +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecDelegatedAccess.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecDelegatedAccess.ps1 new file mode 100644 index 0000000000000..8a0c7d1220e52 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecDelegatedAccess.ps1 @@ -0,0 +1,121 @@ +function Get-CIPPBecDelegatedAccess { + <# + .SYNOPSIS + Lists the other mailboxes the investigated account can reach, and what the attacker did in them. + .DESCRIPTION + A compromised account reaches every mailbox it has delegate access to. The mailboxes come from: + - CIPP's permission cache (the reverse lookup of the mailbox and calendar permission reports, + Get-CIPPMailboxPermissionReport / Get-CIPPCalendarPermissionReport -ByUser), which may be + a few hours old; + - grants to this account recorded in the window's audit log (Add-MailboxPermission, + Add-RecipientPermission, folder grants), which catch a fresh or already-removed grant; + - mailbox activity by this account in a mailbox it does not own (MailItemsAccessed and sends + logged as a delegate), which catches access neither of the above knows about. + Each mailbox row carries its access rights and where they are known from, the delegate + activity counted in it, and the item-level attacker activity (opened, synced, sent) in it. + A permission cache that has not been built is reported as an error while the audit and + activity sources still count. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserPrincipalName + The investigated user. + .PARAMETER UserDisplayName + The user's display name (calendar permissions often name the delegate that way). + .PARAMETER PermissionChanges + The window's mailbox permission changes (MailboxPermissionChanges). + .PARAMETER MailActivity + The mailbox activity counts (MailActivity rows). + .PARAMETER AttackerMail + The attacker-side mail rows (Get-CIPPBecAttackerActivity Mail data). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [string]$UserDisplayName, + [object[]]$PermissionChanges = @(), + [object[]]$MailActivity = @(), + [object[]]$AttackerMail = @() + ) + + $IsUser = { param($Value) $Text = ([string]$Value).Trim(); $Text -and ($Text -ieq $UserPrincipalName -or ($UserDisplayName -and $Text -ieq $UserDisplayName)) } + $Mailboxes = @{} + $Touch = { + param($Mailbox) + $Key = ([string]$Mailbox).Trim().ToLowerInvariant() + if (-not $Mailboxes.ContainsKey($Key)) { + $Mailboxes[$Key] = [pscustomobject]@{ + Mailbox = [string]$Mailbox; AccessRights = [System.Collections.Generic.HashSet[string]]::new(); Sources = [System.Collections.Generic.HashSet[string]]::new() + GrantedInWindow = $false; DelegateActivity = 0; AttackerOpened = 0; AttackerSynced = 0; AttackerSent = 0; LastAttackerActivity = $null + } + } + $Mailboxes[$Key] + } + $Errors = [System.Collections.Generic.List[string]]::new() + + try { + foreach ($Entry in @(Get-CIPPMailboxPermissionReport -TenantFilter $TenantFilter -ByUser | Where-Object { & $IsUser $_.User })) { + foreach ($Permission in @($Entry.Permissions)) { + $Target = if ($Permission.MailboxUPN) { $Permission.MailboxUPN } else { $Permission.Mailbox } + if (& $IsUser $Target) { continue } + $Row = & $Touch $Target + foreach ($Right in @(([string]$Permission.AccessRights) -split ',\s*' | Where-Object { $_ })) { $null = $Row.AccessRights.Add($Right) } + $null = $Row.Sources.Add('Permission cache') + } + } + } catch { + $Errors.Add("mailbox permission cache: $($_.Exception.Message)") + } + try { + foreach ($Entry in @(Get-CIPPCalendarPermissionReport -TenantFilter $TenantFilter -ByUser | Where-Object { & $IsUser $_.User })) { + foreach ($Permission in @($Entry.Permissions)) { + if (& $IsUser $Permission.CalendarUPN) { continue } + $Row = & $Touch $Permission.CalendarUPN + $null = $Row.AccessRights.Add("Calendar: $($Permission.AccessRights)") + $null = $Row.Sources.Add('Permission cache') + } + } + } catch { + $Errors.Add("calendar permission cache: $($_.Exception.Message)") + } + + foreach ($Change in @($PermissionChanges | Where-Object { $_ -and (& $IsUser $_.Trustee) -and [string]$_.Operation -match '^(Add-|Update|AddFolder)' })) { + $Target = if ($Change.AuditData.MailboxOwnerUPN) { $Change.AuditData.MailboxOwnerUPN } else { $Change.ObjectId } + if (-not $Target -or (& $IsUser $Target)) { continue } + $Row = & $Touch $Target + foreach ($Right in @(@($Change.Permissions) -split ',\s*' | Where-Object { $_ })) { $null = $Row.AccessRights.Add([string]$Right) } + $null = $Row.Sources.Add('Granted in the window') + $Row.GrantedInWindow = $true + } + + foreach ($Activity in @($MailActivity | Where-Object { $_ -and $_.MailboxOwner -and -not (& $IsUser $_.MailboxOwner) })) { + $Row = & $Touch $Activity.MailboxOwner + $Row.DelegateActivity = $Row.DelegateActivity + [int]$Activity.Count + $null = $Row.Sources.Add('Delegate activity') + } + foreach ($Item in @($AttackerMail | Where-Object { $_ -and $_.MailboxOwner -and -not (& $IsUser $_.MailboxOwner) })) { + $Row = & $Touch $Item.MailboxOwner + if ($Item.Operation -eq 'MailItemsAccessed' -and $Item.AccessType -eq 'Sync') { $Row.AttackerSynced++ } + elseif ($Item.Operation -eq 'MailItemsAccessed') { $Row.AttackerOpened++ } + elseif ($Item.Operation -in @('Send', 'SendAs', 'SendOnBehalf')) { $Row.AttackerSent++ } + if ($Item.When -and (-not $Row.LastAttackerActivity -or [string]$Item.When -gt $Row.LastAttackerActivity)) { $Row.LastAttackerActivity = [string]$Item.When } + } + + $Rows = @($Mailboxes.Values | ForEach-Object { + [pscustomobject]@{ + Mailbox = $_.Mailbox + AccessRights = @($_.AccessRights | Sort-Object) -join ', ' + KnownFrom = @($_.Sources | Sort-Object) -join ', ' + GrantedInWindow = $_.GrantedInWindow + DelegateActivity = $_.DelegateActivity + AttackerOpened = $_.AttackerOpened + AttackerSynced = $_.AttackerSynced + AttackerSent = $_.AttackerSent + LastAttackerActivity = $_.LastAttackerActivity + Flagged = [bool]($_.AttackerOpened + $_.AttackerSynced + $_.AttackerSent -gt 0 -or $_.GrantedInWindow) + } + } | Sort-Object -Property @{ Expression = { $_.Flagged }; Descending = $true }, @{ Expression = { $_.AttackerOpened + $_.AttackerSynced + $_.AttackerSent }; Descending = $true }, Mailbox) + New-CIPPBecCollectorResult -Data $Rows -Error $(if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null }) +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecDirectoryAudits.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecDirectoryAudits.ps1 new file mode 100644 index 0000000000000..7627a1bb60c84 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecDirectoryAudits.ps1 @@ -0,0 +1,90 @@ +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). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)]$Heuristics + ) + + $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=999&`$select=$Select"; headers = @{ ConsistencyLevel = 'eventual' } } + @{ id = 'Actor'; method = 'GET'; url = "auditLogs/directoryAudits?`$filter=activityDateTime ge $Start and initiatedBy/user/id eq '$SafeId'&`$top=999&`$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() + $Partial = [System.Collections.Generic.List[string]]::new() + 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) + # The batch helper follows every nextLink; it flags the item when a continuation page failed. + if ($Response.PagingIncomplete) { $Partial.Add("$Direction query: $($Response.PagingError ?? 'a continuation page failed')") } + 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 + InitiatedById = [string]($Item.initiatedBy.user.id ?? $Item.initiatedBy.app.appId ?? $Item.initiatedBy.app.servicePrincipalId) + ClientIP = ConvertTo-CIPPBecHostAddress -Address $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 ($Partial.Count -eq 0 -and $Errors.Count -eq 0) -Cap ($(if ($Partial.Count -gt 0) { $Partial -join '; ' } else { $null })) -Error $ErrorText +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1 new file mode 100644 index 0000000000000..e5a810710dc84 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecErrorInfo.ps1 @@ -0,0 +1,56 @@ +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 licensed: the device service rejects the tenant outright. A licence gap, not a failure. + if ($Raw -match '(?i)not applicable to (the )?target tenant') { + return [pscustomobject]@{ + Message = 'Intune is not licensed for this tenant.' + Skipped = $true + Requirement = 'requires an Intune licence' + } + } + # 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/Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1 new file mode 100644 index 0000000000000..b7728b4b6c953 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecHeuristics.ps1 @@ -0,0 +1,27 @@ +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 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. Read once per run. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Heuristics = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\BecHeuristics.json')) | ConvertFrom-Json -ErrorAction Stop + + $CatalogNames = try { + $RiskyPermissions = [System.IO.File]::ReadAllText((Join-Path $env:CIPPRootPath 'Config\RiskyPermissions.json')) | ConvertFrom-Json -ErrorAction Stop + @($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 + return $Heuristics +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPGuidance.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPGuidance.ps1 new file mode 100644 index 0000000000000..032efa75f0fdf --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPGuidance.ps1 @@ -0,0 +1,78 @@ +function Get-CIPPBecIPGuidance { + <# + .SYNOPSIS + Collects the address lists that tell the BEC investigation which IPs are known good or bad. + .DESCRIPTION + One entry per address or CIDR range: { Range, Prefix, Verdict (Trusted|Blocked), Strength + (List|Hint), Source, Scope, Note }. + - List (decides the verdict): CIPP's IP allow/block list for this tenant and AllTenants, where the + most specific range wins and a tenant entry beats an AllTenants one (Resolve-CIPPIPAllowBlockList). + - Hint (moves the score only): Conditional Access named locations marked trusted, and the + Exchange tenant allow/block list and connection-filter IP lists. The Exchange lists describe + sending mail servers rather than sign-ins, so they nudge rather than decide; hyphenated + ranges in the connection filter are skipped. + Each source degrades on its own: a failure is reported in the result's Error and the other + sources still count. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER Anchor + Anchor mailbox for the Exchange requests. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$Anchor + ) + + $Entries = [System.Collections.Generic.List[object]]::new() + $Errors = [System.Collections.Generic.List[string]]::new() + $AddEntry = { + param($Value, $Verdict, $Strength, $Source, $Scope, $Note) + $Range = try { ConvertTo-CIPPIPRange -Value ([string]$Value) } catch { $null } + if (-not $Range) { return } + $Prefix = if ($Range -match '/(\d+)$') { [int]$Matches[1] } elseif ($Range -match ':') { 128 } else { 32 } + $Entries.Add([pscustomobject]@{ Range = $Range; Prefix = $Prefix; Verdict = $Verdict; Strength = $Strength; Source = $Source; Scope = $Scope; Note = [string]$Note }) + } + + try { + foreach ($Entry in @(Get-CIPPIPAllowBlockList -TenantFilter $TenantFilter)) { + & $AddEntry $Entry.Range $Entry.State 'List' $(if ($Entry.Scope -eq 'AllTenants') { 'CIPP IP list (all tenants)' } else { 'CIPP IP list (this tenant)' }) $Entry.Scope $Entry.Note + } + } catch { + $Errors.Add("CIPP IP list: $($_.Exception.Message)") + } + + try { + $Locations = @(New-GraphGetRequest -uri 'https://graph.microsoft.com/beta/identity/conditionalAccess/namedLocations?$top=999' -tenantid $TenantFilter -AsApp $true) + foreach ($Location in @($Locations | Where-Object { $_.'@odata.type' -eq '#microsoft.graph.ipNamedLocation' -and $_.isTrusted -eq $true })) { + foreach ($IpRange in @($Location.ipRanges)) { & $AddEntry $IpRange.cidrAddress 'Trusted' 'Hint' "Trusted named location '$($Location.displayName)'" 'Tenant' $null } + } + } catch { + $Errors.Add("named locations: $((Get-NormalizedError -message $_.Exception.Message))") + } + + try { + foreach ($Item in @(Get-CIPPTenantAllowBlockListItems -TenantFilter $TenantFilter -ListType 'IP')) { + $Verdict = if ([string]$Item.Action -eq 'Block') { 'Blocked' } else { 'Trusted' } + & $AddEntry $Item.Value $Verdict 'Hint' "Tenant allow/block list ($($Item.Action))" 'Tenant' $Item.Notes + } + } catch { + $Errors.Add("tenant allow/block list: $((Get-NormalizedError -message $_.Exception.Message))") + } + + try { + $ExoParams = @{ tenantid = $TenantFilter; cmdlet = 'Get-HostedConnectionFilterPolicy' } + if ($Anchor) { $ExoParams.Anchor = $Anchor } + foreach ($Policy in @(New-ExoRequest @ExoParams)) { + foreach ($Value in @($Policy.IPAllowList)) { & $AddEntry $Value 'Trusted' 'Hint' "Connection filter allow list ($($Policy.Name))" 'Tenant' $null } + foreach ($Value in @($Policy.IPBlockList)) { & $AddEntry $Value 'Blocked' 'Hint' "Connection filter block list ($($Policy.Name))" 'Tenant' $null } + } + } catch { + $Errors.Add("connection filter: $((Get-NormalizedError -message $_.Exception.Message))") + } + + $ErrorText = if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null } + return New-CIPPBecCollectorResult -Data @($Entries) -Error $ErrorText +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPPeers.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPPeers.ps1 new file mode 100644 index 0000000000000..1403c741afa15 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPPeers.ps1 @@ -0,0 +1,92 @@ +function Get-CIPPBecIPPeers { + <# + .SYNOPSIS + Finds the other accounts in the tenant that signed in from each address. + .DESCRIPTION + For each IP, one Graph batch reads the tenant's interactive sign-ins from it (paged to the end) + and the first page of non-interactive ones (an office egress address can carry tens of + thousands of token refreshes; the first 999 already show whether colleagues use it). It + answers two opposite questions: + - Many colleagues on the address before the window: an office or VPN exit - evidence the user's. + - Other accounts on it only during the window, with no history: the attacker reaching further. + Returns a hashtable keyed by IP: { IP, OtherUsers, OtherUsersBefore, OtherUsersInWindowOnly, + Users[], Accounts[], Sampled, Error }. Accounts carries, per other account, its successful and + failed sign-ins and first/last seen (the blast radius of an attacker address). The investigated + user is excluded from every count. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The investigated user's object id (excluded from the counts). + .PARAMETER IPs + The addresses to look up (host form, no port). + .PARAMETER StartDate + How far back to look (UTC) - the baseline start. + .PARAMETER WindowStart + The start of the investigation window (UTC), which splits "before" from "in window". + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [string[]]$IPs = @(), + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$WindowStart + ) + + $Result = @{} + $Targets = @($IPs | Where-Object { $_ } | Select-Object -Unique) + if ($Targets.Count -eq 0) { return $Result } + + $From = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Select = 'userId,userPrincipalName,createdDateTime,status' + $Requests = [System.Collections.Generic.List[object]]::new() + $NoPaginate = [System.Collections.Generic.List[string]]::new() + for ($i = 0; $i -lt $Targets.Count; $i++) { + $SafeIp = ConvertTo-CIPPODataFilterValue -Value ([string]$Targets[$i]) -Type String + $Filter = "ipAddress eq '$SafeIp' and createdDateTime ge $From" + $Requests.Add(@{ id = "i$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter&`$top=999&`$select=$Select" }) + $Requests.Add(@{ id = "n$i"; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter and signInEventTypes/any(t: t eq 'nonInteractiveUser')&`$top=999&`$select=$Select" }) + $NoPaginate.Add("n$i") + } + $Responses = @(New-GraphBulkRequest -Requests @($Requests) -tenantid $TenantFilter -asapp $true -Version 'beta' -NoPaginateIds @($NoPaginate)) + + $Window = $WindowStart.ToUniversalTime() + for ($i = 0; $i -lt $Targets.Count; $i++) { + $IP = [string]$Targets[$i] + $Parts = @($Responses | Where-Object { $_.id -in @("i$i", "n$i") }) + $Failed = @($Parts | Where-Object { [int]$_.status -ge 400 }) + $Before = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $During = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + $Accounts = @{} + foreach ($SignIn in @($Parts | Where-Object { [int]$_.status -lt 400 } | ForEach-Object { $_.body.value })) { + if (-not $SignIn -or -not $SignIn.userId -or $SignIn.userId -eq $UserId) { continue } + $Who = if ($SignIn.userPrincipalName) { [string]$SignIn.userPrincipalName } else { [string]$SignIn.userId } + $When = try { ([datetime]$SignIn.createdDateTime).ToUniversalTime() } catch { $null } + if ($When -and $When -lt $Window) { $null = $Before.Add($Who) } else { $null = $During.Add($Who) } + $Key = $Who.ToLowerInvariant() + if (-not $Accounts.ContainsKey($Key)) { $Accounts[$Key] = [pscustomobject]@{ UserPrincipalName = $Who; UserId = [string]$SignIn.userId; Successful = 0; Failed = 0; FirstSeen = $null; LastSeen = $null } } + $Account = $Accounts[$Key] + if ([int]$SignIn.status.errorCode -eq 0) { $Account.Successful++ } else { $Account.Failed++ } + if ($When) { + $Stamp = $When.ToString('yyyy-MM-ddTHH:mm:ssZ') + if (-not $Account.FirstSeen -or $Stamp -lt $Account.FirstSeen) { $Account.FirstSeen = $Stamp } + if (-not $Account.LastSeen -or $Stamp -gt $Account.LastSeen) { $Account.LastSeen = $Stamp } + } + } + $All = @(@($Before) + @($During) | Select-Object -Unique) + $Result[$IP] = [pscustomobject]@{ + IP = $IP + OtherUsers = $All.Count + OtherUsersBefore = $Before.Count + OtherUsersInWindowOnly = @($During | Where-Object { -not $Before.Contains($_) }).Count + Users = @($All | Sort-Object) + # ponytail: capped at 100 per address (an office exit can carry hundreds); the counts above stay exact + Accounts = @($Accounts.Values | Sort-Object -Property LastSeen -Descending | Select-Object -First 100) + Sampled = [bool](@($Parts | Where-Object { $_.id -eq "n$i" -and $_.body.'@odata.nextLink' }).Count) + Error = if ($Failed.Count -gt 0) { [string]$Failed[0].body.error.message } else { $null } + } + } + return $Result +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPVerdicts.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPVerdicts.ps1 new file mode 100644 index 0000000000000..9a953423fa579 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecIPVerdicts.ps1 @@ -0,0 +1,325 @@ +function Get-CIPPBecIPVerdicts { + <# + .SYNOPSIS + Decides, per IP address, whether the investigated account's activity came from the attacker or the user. + .DESCRIPTION + Pure function over what the run already collected. Every address seen in the window's sign-ins + or on an audited action gets a verdict, a score and the reasons behind it: + - Compromised / Safe: an investigator override for this case, else CIPP's IP allow/block list + (Blocked / Trusted). These decide outright. + - Service: a Microsoft network address the user never signed in from (Exchange and other + services act from their own addresses), one whose sign-ins are all by a service + application (CIPP's own, or Microsoft's Partner Customer Delegated Administration - see + ipVerdict.serviceAppIds), or one seen only on CIPP or partner actions. An + address the user signed in from is scored normally even on Microsoft's network - attackers + rent Azure machines. + - LikelyAttacker / Suspicious / Unknown / LikelyUser: the heuristic score against the + ipVerdict thresholds. + Heuristics (weights in BecHeuristics.json ipVerdict.weights) raise the score for an address + behind a flagged action, a hosting or proxy network, a foreign location, one new to the user's + sign-in baseline, a risky sign-in, a scripted client, or other accounts appearing on it only + during the window; they lower it for an address, network or location the user regularly used + before the window, a compliant device, colleagues on it before the window, a trusted named + location, the address of a technician who ran or reviewed the investigation (most likely the + partner's own, so a strong start towards trusted - but still judged, since a technician's + address can be shared or wrong), or an address with only failed sign-ins (spray noise, which is also capped at + Suspicious). A final pass lifts addresses that share an Entra or mailbox session with a + likely-attacker address, because one session moving between addresses is one actor. + .PARAMETER SignIns + The window's interactive sign-ins (IPAddress, Status, Country, City, ASN, RiskLevelDuringSignIn, + UserAgent, DeviceCompliant, SessionId, CreatedDateTime). + .PARAMETER NonInteractiveSignIns + The window's non-interactive sign-ins, same shape. + .PARAMETER Events + Audited actions: { IP, Kind, Flagged, ActorKind, When, SessionIds } (ConvertTo-CIPPBecIPEvents). + .PARAMETER Baseline + The sign-in baseline profile (Get-CIPPBecSignInBaseline Data), or $null. + .PARAMETER Guidance + Address list entries (Get-CIPPBecIPGuidance Data). + .PARAMETER Overrides + This case's investigator overrides: { Range, Verdict (Safe|Compromised), Note }. + .PARAMETER Peers + Hashtable keyed by IP (Get-CIPPBecIPPeers). + .PARAMETER Geo + Hashtable keyed by IP: { CountryOrRegion, City, Proxy, Hosting, ASName }. + .PARAMETER UsageLocation + The user's usage location (two-letter country). + .PARAMETER Heuristics + The BEC heuristics object (ipVerdict section). + .PARAMETER TechnicianIPs + Addresses of the technicians who ran or reviewed the case ({ IP, By }). + .PARAMETER CippAppId + The CIPP application id; sign-ins by it (and by ipVerdict.serviceAppIds) are service sign-ins. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [object[]]$SignIns = @(), + [object[]]$NonInteractiveSignIns = @(), + [object[]]$Events = @(), + $Baseline, + [object[]]$Guidance = @(), + [object[]]$Overrides = @(), + [hashtable]$Peers = @{}, + [hashtable]$Geo = @{}, + [string]$UsageLocation, + $Heuristics, + [object[]]$TechnicianIPs = @(), + [string]$CippAppId = $env:ApplicationID + ) + + $Cfg = $Heuristics.ipVerdict + $W = $Cfg.weights + $Weight = { param($Name, $Default) [int]($W.$Name ?? $Default) } + $LikelyAttackerAt = [int]($Cfg.thresholds.likelyAttacker ?? 6) + $SuspiciousAt = [int]($Cfg.thresholds.suspicious ?? 3) + $LikelyUserAt = [int]($Cfg.thresholds.likelyUser ?? -3) + $MinBaseline = [int]($Cfg.minBaselineSignIns ?? 10) + $RegularShare = [double]($Cfg.regularShare ?? 0.05) + $RegularDays = [int]($Cfg.regularDays ?? 3) + $KnownAsnShare = [double]($Cfg.knownAsnShare ?? 0.2) + $KnownLocationShare = [double]($Cfg.knownLocationShare ?? 0.1) + $ServiceAsn = [string]($Cfg.serviceAsnRegex ?? '(?i)microsoft') + $ScriptedAgent = [string]($Cfg.scriptedUserAgentRegex ?? '(?i)(python|axios|curl|okhttp|go-http-client|node-fetch|powershell|libwww|java/|httpclient|postman)') + $ServiceActors = @('CIPP', 'Partner', 'OtherPartner') + $ServiceApps = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($AppId in @(@($CippAppId) + @($Cfg.serviceAppIds.PSObject.Properties.Name) | Where-Object { $_ })) { $null = $ServiceApps.Add([string]$AppId) } + + $HostOf = { param($Value) ConvertTo-CIPPBecHostAddress -Address ([string]$Value) } + $Truthy = { param($Value) $Value -eq $true -or [string]$Value -eq 'True' } + + # --- gather everything known about each address --- + $IPs = [ordered]@{} + $Touch = { + param($IP) + if (-not $IPs.Contains($IP)) { + $IPs[$IP] = [pscustomobject]@{ + IP = $IP; SignIns = 0; NonInteractive = 0; Successful = 0; Failed = 0; FirstSeen = $null; LastSeen = $null + Country = $null; City = $null; ASN = $null; Risk = 'none'; Scripted = $false; Compliant = $false + Kinds = [System.Collections.Generic.HashSet[string]]::new(); FlaggedKinds = [System.Collections.Generic.HashSet[string]]::new() + Events = 0; ActorKinds = [System.Collections.Generic.HashSet[string]]::new(); Sessions = [System.Collections.Generic.HashSet[string]]::new(); CippSignIns = 0 + } + } + $IPs[$IP] + } + $Seen = { + param($Entry, $When) + $At = try { if ($When) { ([datetime]$When).ToUniversalTime() } else { $null } } catch { $null } + if (-not $At) { return } + if (-not $Entry.FirstSeen -or $At -lt $Entry.FirstSeen) { $Entry.FirstSeen = $At } + if (-not $Entry.LastSeen -or $At -gt $Entry.LastSeen) { $Entry.LastSeen = $At } + } + $RiskRank = @{ none = 0; hidden = 0; low = 1; medium = 2; high = 3 } + foreach ($Set in @(@{ Rows = $SignIns; Interactive = $true }, @{ Rows = $NonInteractiveSignIns; Interactive = $false })) { + foreach ($SignIn in @($Set.Rows | Where-Object { $_ })) { + $IP = & $HostOf $SignIn.IPAddress + if (-not $IP) { continue } + $Entry = & $Touch $IP + if ($Set.Interactive) { $Entry.SignIns++ } else { $Entry.NonInteractive++ } + if ($SignIn.AppId -and $ServiceApps.Contains([string]$SignIn.AppId)) { $Entry.CippSignIns++ } + if ($SignIn.Status -eq 'Success') { $Entry.Successful++ } else { $Entry.Failed++ } + & $Seen $Entry $SignIn.CreatedDateTime + if (-not $Entry.Country -and $SignIn.Country) { $Entry.Country = [string]$SignIn.Country; $Entry.City = [string]$SignIn.City } + if (-not $Entry.ASN -and $SignIn.ASN) { $Entry.ASN = [string]$SignIn.ASN } + $Level = ([string]$SignIn.RiskLevelDuringSignIn).ToLowerInvariant() + if ([int]($RiskRank[$Level] ?? 0) -gt [int]($RiskRank[$Entry.Risk] ?? 0)) { $Entry.Risk = $Level } + if ($SignIn.UserAgent -and [string]$SignIn.UserAgent -match $ScriptedAgent) { $Entry.Scripted = $true } + if (& $Truthy $SignIn.DeviceCompliant) { $Entry.Compliant = $true } + if ($SignIn.SessionId) { $null = $Entry.Sessions.Add("entra:$($SignIn.SessionId)") } + } + } + foreach ($Activity in @($Events | Where-Object { $_ })) { + $IP = & $HostOf $Activity.IP + if (-not $IP) { continue } + $Entry = & $Touch $IP + $Entry.Events++ + if ($Activity.Kind) { $null = $Entry.Kinds.Add([string]$Activity.Kind) } + if ($Activity.Flagged -eq $true -and $Activity.Kind) { $null = $Entry.FlaggedKinds.Add([string]$Activity.Kind) } + $null = $Entry.ActorKinds.Add([string]($Activity.ActorKind ?? 'User')) + foreach ($Session in @($Activity.SessionIds | Where-Object { $_ })) { $null = $Entry.Sessions.Add("mailbox:$Session") } + & $Seen $Entry $Activity.When + } + + # --- lookups --- + $BaselineOk = $Baseline -and [int]$Baseline.Successful -ge $MinBaseline + $BaselineIPs = @{} + foreach ($Row in @($Baseline.IPs | Where-Object { $_ })) { $BaselineIPs[[string]$Row.IP] = $Row } + $BaselineAsns = @{} + foreach ($Row in @($Baseline.ASNs | Where-Object { $_ })) { $BaselineAsns[[string]$Row.ASN] = $Row } + $BaselineCountries = @{} + $BaselinePlaces = @{} + foreach ($Row in @($Baseline.Locations | Where-Object { $_ })) { + $BaselineCountries[[string]$Row.Country] = [double]($BaselineCountries[[string]$Row.Country] ?? 0) + [double]$Row.Share + $BaselinePlaces["$($Row.Country)|$($Row.City)"] = $Row + } + $ListEntries = @($Guidance | Where-Object { $_ -and $_.Strength -eq 'List' } | ForEach-Object { + [pscustomobject]@{ Range = $_.Range; Prefix = $_.Prefix; State = $_.Verdict; Scope = $_.Scope; Source = $_.Source; Note = $_.Note } + }) + $Hints = @($Guidance | Where-Object { $_ -and $_.Strength -eq 'Hint' }) + $Technicians = @{} + foreach ($Tech in @($TechnicianIPs | Where-Object { $_ -and $_.IP })) { + $TechIP = & $HostOf $Tech.IP + if ($TechIP -and -not $Technicians.ContainsKey($TechIP)) { $Technicians[$TechIP] = $Tech } + } + $CaseEntries = @($Overrides | Where-Object { $_ -and $_.Range } | ForEach-Object { + $Range = try { ConvertTo-CIPPIPRange -Value ([string]$_.Range) } catch { $null } + if ($Range) { + $Prefix = if ($Range -match '/(\d+)$') { [int]$Matches[1] } elseif ($Range -match ':') { 128 } else { 32 } + [pscustomobject]@{ Range = $Range; Prefix = $Prefix; State = $(if ($_.Verdict -eq 'Compromised') { 'Blocked' } else { 'Trusted' }); Scope = 'Tenant'; Note = $_.Note } + } + }) + + # --- score --- + $Rows = [System.Collections.Generic.List[object]]::new() + foreach ($Entry in $IPs.Values) { + $IP = $Entry.IP + $GeoInfo = $Geo[$IP] + $Country = if ($Entry.Country) { $Entry.Country } else { [string]$GeoInfo.CountryOrRegion } + if ($Country -eq 'Unknown') { $Country = $null } + $City = if ($Entry.City) { $Entry.City } else { [string]$GeoInfo.City } + $AsName = [string]$GeoInfo.ASName + $Hosting = & $Truthy $GeoInfo.Hosting + $Proxy = & $Truthy $GeoInfo.Proxy + $Peer = $Peers[$IP] + $Reasons = [System.Collections.Generic.List[object]]::new() + $Add = { param($Code, $Points, $Text) $Reasons.Add([pscustomobject]@{ Code = $Code; Weight = [int]$Points; Text = $Text }) } + + $Flagged = @($Entry.FlaggedKinds) + if ($Flagged.Count -gt 0) { + & $Add 'FlaggedAction' ((& $Weight 'flaggedAction' 4) + (& $Weight 'flaggedActionExtraKind' 1) * ($Flagged.Count - 1)) "Behind flagged activity: $($Flagged -join ', ')" + } + # an address the user signed in from is scored even on Microsoft's network (attackers rent Azure + # machines); a Microsoft address with no sign-ins is classed as a service before the score counts + if ($Hosting -or $Proxy) { + & $Add 'HostingOrProxy' (& $Weight 'hostingOrProxy' 3) "$(if ($Proxy) { 'Proxy/VPN' } else { 'Hosting' }) network$(if ($AsName) { " ($AsName)" })" + } + if ($UsageLocation -and $Country -and $Country -ne $UsageLocation) { + & $Add 'Foreign' (& $Weight 'foreign' 2) "Outside the usage location ($Country, expected $UsageLocation)" + } + if ($Entry.Risk -in @('medium', 'high')) { & $Add 'RiskySignIn' (& $Weight 'riskySignIn' 3) "Entra rated a sign-in $($Entry.Risk) risk" } + elseif ($Entry.Risk -eq 'low') { & $Add 'RiskySignInLow' (& $Weight 'riskySignInLow' 1) 'Entra rated a sign-in low risk' } + if ($Entry.Scripted) { & $Add 'ScriptedClient' (& $Weight 'scriptedClient' 3) 'A sign-in used a scripting or automation user agent' } + + $Known = $BaselineIPs[$IP] + if ($Known) { + if ([double]$Known.Share -ge $RegularShare -or [int]$Known.Days -ge $RegularDays) { + & $Add 'BaselineRegular' (& $Weight 'baselineRegular' -4) "The user's regular address before the window ($([math]::Round([double]$Known.Share * 100, 1))% of sign-ins, $($Known.Days) day(s))" + } else { + & $Add 'BaselineSeen' (& $Weight 'baselineSeen' -2) "Used by the user before the window ($($Known.SignIns) sign-in(s))" + } + } elseif ($BaselineOk) { + & $Add 'NewToUser' (& $Weight 'newToUser' 2) 'Never used by the user before the window' + } + if ($BaselineOk -and $Entry.ASN) { + $KnownAsn = $BaselineAsns[[string]$Entry.ASN] + if ($KnownAsn -and [double]$KnownAsn.Share -ge $KnownAsnShare) { & $Add 'KnownNetwork' (& $Weight 'knownNetwork' -1) "The user's usual network (AS$($Entry.ASN))" } + elseif (-not $KnownAsn -and -not $Known) { & $Add 'NewNetwork' (& $Weight 'newNetwork' 1) "A network (AS$($Entry.ASN)) the user never signed in from" } + } + if ($BaselineOk -and $Country) { + $Place = $BaselinePlaces["$Country|$City"] + if ($Place -and [double]$Place.Share -ge $KnownLocationShare) { & $Add 'KnownLocation' (& $Weight 'knownLocation' -1) "The user's usual location ($City, $Country)" } + elseif (-not $BaselineCountries.ContainsKey($Country) -and -not $Known) { & $Add 'NewLocation' (& $Weight 'newLocation' 1) "A country ($Country) the user never signed in from" } + } + if ($Entry.Compliant) { & $Add 'CompliantDevice' (& $Weight 'compliantDevice' -2) 'Signed in from a compliant device' } + if ($Peer) { + if ([int]$Peer.OtherUsersBefore -ge 2 -and -not ($Hosting -or $Proxy)) { + & $Add 'Colleagues' (& $Weight 'colleagues' -2) "$($Peer.OtherUsersBefore) other account(s) used it before the window (office or VPN exit)" + } elseif ([int]$Peer.OtherUsersInWindowOnly -ge 2 -and [int]$Peer.OtherUsersBefore -eq 0) { + & $Add 'WiderAttack' (& $Weight 'widerAttack' 2) "$($Peer.OtherUsersInWindowOnly) other account(s) signed in from it only during the window" + } + } + foreach ($Hint in @($Hints | Where-Object { Test-IpInRange -IPAddress $IP -Range $_.Range })) { + if ($Hint.Verdict -eq 'Trusted') { + $Points = if ($Hint.Source -like 'Trusted named location*') { & $Weight 'trustedNamedLocation' -4 } else { & $Weight 'allowListHint' -1 } + & $Add 'AllowHint' $Points $Hint.Source + } else { + & $Add 'BlockHint' (& $Weight 'blockListHint' 2) $Hint.Source + } + } + $Technician = $Technicians[$IP] + if ($Technician) { & $Add 'TechnicianAddress' (& $Weight 'technicianAddress' -4) "The address of the technician who ran or reviewed this case$(if ($Technician.By) { " ($($Technician.By))" }) - most likely the partner's" } + $OnlyFailed = ($Entry.SignIns + $Entry.NonInteractive) -gt 0 -and $Entry.Successful -eq 0 -and $Entry.Events -eq 0 + if ($OnlyFailed) { & $Add 'OnlyFailed' (& $Weight 'onlyFailedSignIns' -3) 'Only failed sign-ins (password spray or lockout noise)' } + + $Score = [int](($Reasons | Measure-Object -Property Weight -Sum).Sum) + $Rows.Add([pscustomobject]@{ + IP = $IP; Entry = $Entry; Reasons = $Reasons; Score = $Score; OnlyFailed = $OnlyFailed + Country = $Country; City = $City; ASName = $AsName; Hosting = $Hosting; Proxy = $Proxy; Peer = $Peer + }) + } + + $Classify = { + param($Row) + $Case = Resolve-CIPPIPAllowBlockList -IPAddress $Row.IP -Entries $CaseEntries + if ($Case) { + return [pscustomobject]@{ Verdict = $(if ($Case.State -eq 'Blocked') { 'Compromised' } else { 'Safe' }); Source = "Investigator ($($Case.Range))$(if ($Case.Note) { ": $($Case.Note)" })" } + } + $Listed = Resolve-CIPPIPAllowBlockList -IPAddress $Row.IP -Entries $ListEntries + if ($Listed) { + return [pscustomobject]@{ Verdict = $(if ($Listed.State -eq 'Blocked') { 'Compromised' } else { 'Safe' }); Source = "$($Listed.Source) ($($Listed.Range))" } + } + $SignInCount = $Row.Entry.SignIns + $Row.Entry.NonInteractive + if ($SignInCount -eq 0 -and $Row.ASName -match $ServiceAsn) { return [pscustomobject]@{ Verdict = 'Service'; Source = "Microsoft service address ($($Row.ASName))" } } + $OnlyServiceActors = @($Row.Entry.ActorKinds | Where-Object { $_ -notin $ServiceActors }).Count -eq 0 + if ($SignInCount -gt 0 -and $Row.Entry.CippSignIns -eq $SignInCount -and $OnlyServiceActors) { + return [pscustomobject]@{ Verdict = 'Service'; Source = 'Sign-ins only by CIPP or partner delegated administration, not by the user' } + } + if ($SignInCount -eq 0 -and $Row.Entry.ActorKinds.Count -gt 0 -and @($Row.Entry.ActorKinds | Where-Object { $_ -notin $ServiceActors }).Count -eq 0) { + return [pscustomobject]@{ Verdict = 'Service'; Source = 'Only CIPP or partner actions' } + } + $Verdict = if ($Row.Score -ge $LikelyAttackerAt) { 'LikelyAttacker' } elseif ($Row.Score -ge $SuspiciousAt) { 'Suspicious' } elseif ($Row.Score -le $LikelyUserAt) { 'LikelyUser' } else { 'Unknown' } + if ($Row.OnlyFailed -and $Verdict -eq 'LikelyAttacker') { $Verdict = 'Suspicious' } + return [pscustomobject]@{ Verdict = $Verdict; Source = 'Heuristics' } + } + foreach ($Row in $Rows) { $Row | Add-Member -NotePropertyName 'Decision' -NotePropertyValue (& $Classify $Row) -Force } + + # One Entra or mailbox session moving between addresses is one actor: lift the rest of a session + # that includes a likely-attacker address. + $AttackerSessions = [System.Collections.Generic.HashSet[string]]::new() + foreach ($Row in @($Rows | Where-Object { $_.Decision.Verdict -in @('Compromised', 'LikelyAttacker') })) { + foreach ($Session in $Row.Entry.Sessions) { $null = $AttackerSessions.Add($Session) } + } + if ($AttackerSessions.Count -gt 0) { + foreach ($Row in @($Rows | Where-Object { $_.Decision.Verdict -in @('Suspicious', 'Unknown', 'LikelyUser') })) { + $Shared = @($Row.Entry.Sessions | Where-Object { $AttackerSessions.Contains($_) }) + if ($Shared.Count -eq 0) { continue } + $Points = & $Weight 'sharedSession' 3 + $Row.Reasons.Add([pscustomobject]@{ Code = 'SharedSession'; Weight = $Points; Text = "Shares a $(($Shared[0] -split ':')[0]) session with a likely-attacker address" }) + $Row.Score = $Row.Score + $Points + $Row.Decision = & $Classify $Row + } + } + + $Order = @{ Compromised = 0; LikelyAttacker = 1; Suspicious = 2; Unknown = 3; LikelyUser = 4; Safe = 5; Service = 6 } + @($Rows | ForEach-Object { + $Known = $BaselineIPs[$_.IP] + [pscustomobject]@{ + IP = $_.IP + Verdict = $_.Decision.Verdict + Score = $_.Score + Source = $_.Decision.Source + Reasons = @($_.Reasons) + ReasonText = @($_.Reasons | ForEach-Object { "$($_.Text) ($(if ($_.Weight -ge 0) { '+' })$($_.Weight))" }) -join '; ' + Country = $_.Country + City = $_.City + ASN = $_.Entry.ASN + ASName = $_.ASName + Hosting = $_.Hosting + Proxy = $_.Proxy + SignIns = $_.Entry.SignIns + NonInteractiveSignIns = $_.Entry.NonInteractive + SuccessfulSignIns = $_.Entry.Successful + FailedSignIns = $_.Entry.Failed + Activities = $_.Entry.Events + Kinds = @($_.Entry.Kinds | Sort-Object) + FirstSeen = if ($_.Entry.FirstSeen) { $_.Entry.FirstSeen.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + LastSeen = if ($_.Entry.LastSeen) { $_.Entry.LastSeen.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + BaselineShare = if ($Known) { [double]$Known.Share } else { 0 } + BaselineSignIns = if ($Known) { [int]$Known.SignIns } else { 0 } + OtherUsersBefore = if ($_.Peer) { [int]$_.Peer.OtherUsersBefore } else { $null } + OtherUsersInWindowOnly = if ($_.Peer) { [int]$_.Peer.OtherUsersInWindowOnly } else { $null } + OtherUsers = if ($_.Peer) { @($_.Peer.Users) } else { @() } + } + } | Sort-Object -Property @{ Expression = { $Order[$_.Verdict] } }, @{ Expression = { $_.Score }; Descending = $true }) +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailActivity.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailActivity.ps1 new file mode 100644 index 0000000000000..0196d2a5fcc19 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailActivity.ps1 @@ -0,0 +1,146 @@ +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 in the counts. MailItemsAccessed is part of Audit (Standard) for + E3/E5 licences; when the log does not carry it the other operations still count. Each row also + lists the mailbox SessionIds it saw (one session moving between addresses is one actor), and + the raw records ride along on the result (Records, not stored) so the attacker-activity pass + can take item-level detail for the attacker's addresses without searching again. + .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) + $HardDeleteThreshold = [int]($Heuristics.mailActivity.hardDeleteThreshold ?? 20) + + $Groups = @{} + $AllRecords = [System.Collections.Generic.List[object]]::new() + $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](ConvertTo-CIPPBecHostAddress -Address ($AD.ClientIP ?? $AD.ClientIPAddress)) + $ClientInfo = [string]($AD.ClientInfoString ?? $AD.ClientAppId ?? $AD.ClientApplication) + if ($ClientInfo.Length -gt 120) { $ClientInfo = $ClientInfo.Substring(0, 120) + '...' } + # real records carry the access type in OperationProperties; older payloads had it at the top + $AccessType = [string]($AD.MailAccessType ?? (@($AD.OperationProperties) | Where-Object { $_.Name -eq 'MailAccessType' } | Select-Object -First 1).Value) + $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 + SessionIds = [System.Collections.Generic.HashSet[string]]::new() + } + } + $Group = $Groups[$Key] + if ($AD.SessionId) { $null = $Group.SessionIds.Add([string]$AD.SessionId) } + $AllRecords.Add($Record) + $Group.Count = $Group.Count + $Count + $Group.Records = $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 } + $_.SessionIds = @($_.SessionIds) + $_ + } | 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) + } + + $Result = New-CIPPBecCollectorResult -Data $Rows -Complete ($Complete -and $Errors.Count -eq 0) -Cap $Cap -Error ($(if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null })) -Count $Rows.Count + $Result | Add-Member -NotePropertyName 'Summary' -NotePropertyValue $Summary -Force + $Result | Add-Member -NotePropertyName 'Records' -NotePropertyValue $AllRecords.ToArray() -Force + return $Result +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailboxInventory.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailboxInventory.ps1 new file mode 100644 index 0000000000000..978c1946fafbd --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecMailboxInventory.ps1 @@ -0,0 +1,254 @@ +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)) { + @{ 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) { + @{ 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/Modules/CIPPCore/Public/BEC/Get-CIPPBecMessageTrace.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecMessageTrace.ps1 new file mode 100644 index 0000000000000..3b06df8dd301b --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecMessageTrace.ps1 @@ -0,0 +1,98 @@ +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 until a short page ends the window, + 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). + .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 + ) + + 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 (-not $Done -and -not $Stalled) + + return [pscustomobject]@{ + Rows = $Rows.ToArray() + Complete = [bool]$Done + Pages = $Pages + Cap = if ($Stalled) { 'paging stalled (cursor did not advance)' } else { $null } + } +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecNonInteractiveSignIns.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecNonInteractiveSignIns.ps1 new file mode 100644 index 0000000000000..cee091d5b24a1 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecNonInteractiveSignIns.ps1 @@ -0,0 +1,68 @@ +function Get-CIPPBecNonInteractiveSignIns { + <# + .SYNOPSIS + Collects the investigated user's non-interactive sign-ins inside the analysis window. + .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. This reads the beta + signIns endpoint filtered on signInEventTypes nonInteractiveUser and the window start, pages to + the end, 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 StartDate + Window start (UTC). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [string]$UsageLocation, + [Parameter(Mandatory = $true)][datetime]$StartDate + ) + + $SafeId = ConvertTo-CIPPODataFilterValue -Value $UserId -Type Guid + $Start = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Uri = "https://graph.microsoft.com/beta/auditLogs/signIns?`$filter=userId eq '$SafeId' and signInEventTypes/any(t: t eq 'nonInteractiveUser') and createdDateTime ge $Start&`$top=999&`$orderby=createdDateTime desc" + $SignIns = @(New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $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 + RiskEventTypes = @($SignIn.riskEventTypes_v2) + ASN = $SignIn.autonomousSystemNumber + DeviceCompliant = $SignIn.deviceDetail.isCompliant + DeviceManaged = $SignIn.deviceDetail.isManaged + OperatingSystem = $SignIn.deviceDetail.operatingSystem + # tie audited mailbox and file actions back to this token and session + SessionId = $SignIn.sessionId + UniqueTokenId = $SignIn.uniqueTokenIdentifier + AppId = $SignIn.appId + ForeignLocation = $Foreign + } + } + $Data = @($Rows) + return New-CIPPBecCollectorResult -Data $Data +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecReceivedMailFindings.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecReceivedMailFindings.ps1 new file mode 100644 index 0000000000000..fe77694f5700a --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecReceivedMailFindings.ps1 @@ -0,0 +1,184 @@ +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) + $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) + + # Every received message's subject by internet message id, so the attacker-activity pass can name + # the messages the attacker opened without tracing them again (kept in memory, not stored). + $MessageIndex = @{} + $Findings = try { + $Trace = Get-CIPPBecMessageTrace -TenantFilter $TenantFilter -RecipientAddress $UserPrincipalName -StartDate $StartDate -EndDate $EndDate -Anchor $Anchor + $Rows = @($Trace.Rows) + foreach ($Row in $Rows) { if ($Row.MessageId -and -not $MessageIndex.ContainsKey([string]$Row.MessageId)) { $MessageIndex[[string]$Row.MessageId] = [string]$Row.Subject } } + + # 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() } + # The service rejects $filter on recipientEmailAddress ("Invalid filter with propName"), so the whole + # window is read tenant-wide (metadata only) and matched to the mailbox here. The pages are streamed + # through the match, so only this mailbox's rows are ever held however busy the tenant is. + $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=1000" + $Analyzed = @(New-GraphGetRequest -uri $Uri -tenantid $TenantFilter -AsApp $true -Stream | Where-Object { $_ -and ([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 }) + $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 + MessageIndex = $MessageIndex + } +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecRegisteredDevices.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecRegisteredDevices.ps1 new file mode 100644 index 0000000000000..478474077ab18 --- /dev/null +++ b/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/Modules/CIPPCore/Public/BEC/Get-CIPPBecReport.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecReport.ps1 new file mode 100644 index 0000000000000..e4da80db203ff --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecReport.ps1 @@ -0,0 +1,73 @@ +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')) { + if ($Row.PSObject.Properties[$JsonProp] -and $Row.$JsonProp -is [string] -and $Row.$JsonProp) { + # @() because ConvertFrom-Json unrolls a JSON array: a one-entry history would come back as a bare object + 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 + } else { + throw "The results payload for case $CaseId was not found in the BecResults table" + } + } + return $Row + } + return $Rows +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecRiskState.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecRiskState.ps1 new file mode 100644 index 0000000000000..01b3297e1a121 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecRiskState.ps1 @@ -0,0 +1,94 @@ +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. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][datetime]$StartDate + ) + + $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&`$orderby=detectedDateTime desc" -tenantid $TenantFilter) + $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/Modules/CIPPCore/Public/BEC/Get-CIPPBecRogueAppFeed.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecRogueAppFeed.ps1 new file mode 100644 index 0000000000000..74497daa477b1 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecRogueAppFeed.ps1 @@ -0,0 +1,79 @@ +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 memoised per worker for an hour, 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". + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + $Now = (Get-Date).ToUniversalTime() + if ($script:CippBecRogueAppMemo -and $script:CippBecRogueAppMemo.Expires -gt $Now) { + return $script:CippBecRogueAppMemo.Feed + } + + $Apps = @{} + $HuntressAvailable = $false + $HuntressUpdated = $null + $HuntressApps = @() + 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') + } + } 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/Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1 new file mode 100644 index 0000000000000..09d7228557ccb --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecRunSteps.ps1 @@ -0,0 +1,32 @@ +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. The last step is always the location analysis, score and report. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + @( + [pscustomobject]@{ Key = 'AuditLog'; Title = 'Unified audit log: rules, permissions, safelists and sharing' } + [pscustomobject]@{ Key = 'SignIns'; Title = 'Sign-ins and mobile devices' } + [pscustomobject]@{ Key = 'MailboxRules'; Title = 'Inbox rules, safelists and sharing links' } + [pscustomobject]@{ Key = 'SentMail'; Title = 'Sent message trace' } + [pscustomobject]@{ Key = 'Tenant'; Title = 'Tenant users, MFA methods and applications' } + [pscustomobject]@{ Key = 'MailboxInventory'; Title = 'Mailbox state, delegations and add-ins' } + [pscustomobject]@{ Key = 'Grants'; Title = 'Application consents' } + [pscustomobject]@{ Key = 'TransportRules'; Title = 'Transport rules' } + [pscustomobject]@{ Key = 'ReceivedMail'; Title = 'Received mail and Defender verdicts' } + [pscustomobject]@{ Key = 'Directory'; Title = 'Directory audits, registered devices and non-interactive sign-ins' } + [pscustomobject]@{ Key = 'Activity'; Title = 'Mailbox activity and Identity Protection' } + [pscustomobject]@{ Key = 'IPAnalysis'; Title = 'Attacker IPs: sign-in baseline, IP lists and other accounts' } + [pscustomobject]@{ Key = 'AttackerActivity'; Title = 'Attacker activity: mail, files, sharing links, Forms and delegated mailboxes' } + [pscustomobject]@{ Key = 'Score'; Title = 'Location analysis, threat score and report' } + ) +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 new file mode 100644 index 0000000000000..8cbbdb47a4e8f --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecScore.ps1 @@ -0,0 +1,150 @@ +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 + $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 } + # addresses judged the attacker's (by an investigator, the CIPP list or the heuristics) that got in or acted + AttackerIPs = @($Results.IPVerdicts | Where-Object { $_.Verdict -in @('Compromised', 'LikelyAttacker') -and ([int]$_.SuccessfulSignIns -gt 0 -or [int]$_.Activities -gt 0) }).Count + # item-level activity counts only from addresses judged the attacker's, not merely suspicious or unknown + AttackerMailAccess = @($Results.AttackerMailActivity | Where-Object { $_.IPVerdict -in @('Compromised', 'LikelyAttacker') }).Count + AttackerFileAccess = @($Results.AttackerFileActivity | Where-Object { $_.IPVerdict -in @('Compromised', 'LikelyAttacker') }).Count + AttackerForms = @($Results.FormsActivity | Where-Object { $_.Flagged -eq $true -and $_.IPVerdict -in @('Compromised', 'LikelyAttacker') }).Count + OtherAccountsReached = @($Results.BlastRadius | Where-Object { $_.Reached -eq $true }).Count + DelegatedMailboxAttackerAccess = @($Results.AttackerMailActivity | Where-Object { $_.IPVerdict -in @('Compromised', 'LikelyAttacker') -and $_.MailboxOwner -and $Results.UserPrincipalName -and $_.MailboxOwner -ne $Results.UserPrincipalName } | ForEach-Object { $_.MailboxOwner } | Select-Object -Unique).Count + } + + $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' + AttackerIPs = 'Sign-ins or activity from addresses judged to be the attacker' + AttackerMailAccess = 'Mail opened, synced, deleted, moved or sent from an attacker address' + AttackerFileAccess = 'OneDrive/SharePoint files touched from an attacker address' + AttackerForms = 'Microsoft Forms created, edited or shared from an attacker address' + OtherAccountsReached = 'Another account in the tenant signed in or acted from an attacker address' + DelegatedMailboxAttackerAccess = "Another mailbox reached through this account's delegated access from an attacker address" + } + + $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 = [int]($W.$Name ?? 0) + if ($Applied) { $Total = $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() + } +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecSignInBaseline.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecSignInBaseline.ps1 new file mode 100644 index 0000000000000..f6eb1a0dd4209 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecSignInBaseline.ps1 @@ -0,0 +1,125 @@ +function Get-CIPPBecSignInBaseline { + <# + .SYNOPSIS + Profiles where the investigated user normally signs in from, before the investigation window. + .DESCRIPTION + Reads the user's interactive and non-interactive sign-ins (the second is where Exchange, + SharePoint and the other service tokens show up) for the baseline period in one Graph batch, + and reduces them to how often each IP, network (ASN) and location was used. Only successful + sign-ins make an address "known": failed attempts are spray noise and would otherwise teach + the baseline an attacker's address. No sign-in rows are kept, only the aggregates: + { From, To, SignIns, Successful, IPs[{ IP, SignIns, Interactive, NonInteractive, Share, Days, + FirstSeen, LastSeen, ASN, Country, City, Apps }], ASNs[{ ASN, SignIns, Share }], + Locations[{ Country, City, SignIns, Share }] }. Graph keeps sign-ins for 30 days (7 without + Entra P1), so a longer baseline simply returns what exists. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The user's object id. + .PARAMETER StartDate + Baseline start (UTC). + .PARAMETER EndDate + Baseline end (UTC) - the start of the investigation window. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][datetime]$StartDate, + [Parameter(Mandatory = $true)][datetime]$EndDate + ) + + $SafeId = ConvertTo-CIPPODataFilterValue -Value $UserId -Type Guid + $From = $StartDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $To = $EndDate.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') + $Select = 'createdDateTime,ipAddress,autonomousSystemNumber,location,status,conditionalAccessStatus,appDisplayName,resourceDisplayName' + $Filter = "userId eq '$SafeId' and createdDateTime ge $From and createdDateTime lt $To" + $Requests = @( + @{ id = 'Interactive'; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter&`$top=999&`$select=$Select" } + @{ id = 'NonInteractive'; method = 'GET'; url = "auditLogs/signIns?`$filter=$Filter and signInEventTypes/any(t: t eq 'nonInteractiveUser')&`$top=999&`$select=$Select" } + ) + $Responses = @(New-GraphBulkRequest -Requests $Requests -tenantid $TenantFilter -asapp $true -Version 'beta') + + $Errors = [System.Collections.Generic.List[string]]::new() + $Incomplete = $false + $Rows = [System.Collections.Generic.List[object]]::new() + foreach ($Id in @('Interactive', 'NonInteractive')) { + $Response = @($Responses | Where-Object { $_.id -eq $Id }) + $Failed = @($Response | Where-Object { [int]$_.status -ge 400 }) + if ($Response.Count -eq 0 -or $Failed.Count -gt 0) { + $Errors.Add("$Id sign-ins: $(if ($Failed.Count -gt 0) { $Failed[0].body.error.message } else { 'no response' })") + continue + } + if (@($Response | Where-Object { $_.PagingIncomplete }).Count -gt 0) { $Incomplete = $true } + foreach ($SignIn in @($Response.body.value)) { + if ($SignIn) { $Rows.Add([pscustomobject]@{ Kind = $Id; SignIn = $SignIn }) } + } + } + + $ByIP = @{} + $ByAsn = @{} + $ByLocation = @{} + $Successful = 0 + foreach ($Row in $Rows) { + $SignIn = $Row.SignIn + $Ok = $SignIn.conditionalAccessStatus -in @('success', 'notApplied') -and $SignIn.status.errorCode -eq 0 + if (-not $Ok) { continue } + $IP = ConvertTo-CIPPBecHostAddress -Address ([string]$SignIn.ipAddress) + if (-not $IP) { continue } + $Successful++ + $When = try { ([datetime]$SignIn.createdDateTime).ToUniversalTime() } catch { $null } + $Country = [string]$SignIn.location.countryOrRegion + $City = [string]$SignIn.location.city + $Asn = [string]$SignIn.autonomousSystemNumber + if (-not $ByIP.ContainsKey($IP)) { + $ByIP[$IP] = [pscustomobject]@{ IP = $IP; SignIns = 0; Interactive = 0; NonInteractive = 0; Days = [System.Collections.Generic.HashSet[string]]::new(); FirstSeen = $null; LastSeen = $null; Asns = @{}; Places = @{}; Apps = [System.Collections.Generic.HashSet[string]]::new() } + } + $Entry = $ByIP[$IP] + $Entry.SignIns++ + if ($Row.Kind -eq 'Interactive') { $Entry.Interactive++ } else { $Entry.NonInteractive++ } + if ($When) { + $null = $Entry.Days.Add($When.ToString('yyyy-MM-dd')) + if (-not $Entry.FirstSeen -or $When -lt $Entry.FirstSeen) { $Entry.FirstSeen = $When } + if (-not $Entry.LastSeen -or $When -gt $Entry.LastSeen) { $Entry.LastSeen = $When } + } + if ($Asn) { $Entry.Asns[$Asn] = [int]($Entry.Asns[$Asn] ?? 0) + 1; $ByAsn[$Asn] = [int]($ByAsn[$Asn] ?? 0) + 1 } + $Place = "$Country|$City" + if ($Country -or $City) { $Entry.Places[$Place] = [int]($Entry.Places[$Place] ?? 0) + 1; $ByLocation[$Place] = [int]($ByLocation[$Place] ?? 0) + 1 } + foreach ($App in @($SignIn.appDisplayName, $SignIn.resourceDisplayName)) { if ($App) { $null = $Entry.Apps.Add([string]$App) } } + } + + $Share = { param($Count) if ($Successful -gt 0) { [math]::Round($Count / $Successful, 4) } else { 0 } } + $Top = { param([hashtable]$Counts) ($Counts.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 1).Key } + $SignInProfile = [pscustomobject]@{ + From = $From + To = $To + SignIns = $Rows.Count + Successful = $Successful + IPs = @($ByIP.Values | Sort-Object -Property SignIns -Descending | ForEach-Object { + $Place = [string](& $Top $_.Places) + [pscustomobject]@{ + IP = $_.IP + SignIns = $_.SignIns + Interactive = $_.Interactive + NonInteractive = $_.NonInteractive + Share = & $Share $_.SignIns + Days = $_.Days.Count + FirstSeen = if ($_.FirstSeen) { $_.FirstSeen.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + LastSeen = if ($_.LastSeen) { $_.LastSeen.ToString('yyyy-MM-ddTHH:mm:ssZ') } else { $null } + ASN = [string](& $Top $_.Asns) + Country = if ($Place) { ($Place -split '\|', 2)[0] } else { $null } + City = if ($Place) { ($Place -split '\|', 2)[1] } else { $null } + Apps = @($_.Apps | Select-Object -First 5) + } + }) + ASNs = @($ByAsn.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { [pscustomobject]@{ ASN = [string]$_.Key; SignIns = [int]$_.Value; Share = & $Share $_.Value } }) + Locations = @($ByLocation.GetEnumerator() | Sort-Object -Property Value -Descending | ForEach-Object { + $Parts = ([string]$_.Key) -split '\|', 2 + [pscustomobject]@{ Country = $Parts[0]; City = $Parts[1]; SignIns = [int]$_.Value; Share = & $Share $_.Value } + }) + } + $ErrorText = if ($Errors.Count -gt 0) { $Errors -join '; ' } else { $null } + return New-CIPPBecCollectorResult -Data $SignInProfile -Complete (-not $Incomplete) -Cap $(if ($Incomplete) { 'Graph stopped paging part-way' } else { $null }) -Error $ErrorText -Count $Successful +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecTransportRules.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecTransportRules.ps1 new file mode 100644 index 0000000000000..c092f6f6ce80c --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecTransportRules.ps1 @@ -0,0 +1,122 @@ +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) + + $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 = ConvertTo-CIPPBecHostAddress -Address ($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')) + AuditData = $AD + } + } + $Rows = @($Rows | Sort-Object -Property @{ Expression = { $_.Flagged }; Descending = $true }, @{ Expression = { $_.Date }; Descending = $true }) + New-CIPPBecCollectorResult -Data $Rows -Complete $Search.Complete -Cap $Search.Cap -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 + } + } + New-CIPPBecCollectorResult -Data @($Rows | Sort-Object -Property @{ Expression = { $_.ChangedInWindow }; Descending = $true }, Name) + } catch { + New-CIPPBecCollectorResult -Data @() -Error "Get-TransportRule failed: $((Get-NormalizedError -message $_.Exception.Message))" + } + + return [pscustomobject]@{ + Changes = $Changes + Flagged = $Flagged + } +} diff --git a/Modules/CIPPCore/Public/BEC/Get-CIPPBecUserGrants.ps1 b/Modules/CIPPCore/Public/BEC/Get-CIPPBecUserGrants.ps1 new file mode 100644 index 0000000000000..e81c483146bc1 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Get-CIPPBecUserGrants.ps1 @@ -0,0 +1,148 @@ +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 } + return New-CIPPBecCollectorResult -Data $Data -Complete ($Errors.Count -eq 0) -Error $ErrorText +} diff --git a/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecContainment.ps1 b/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecContainment.ps1 new file mode 100644 index 0000000000000..e5824a276abfd --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecContainment.ps1 @@ -0,0 +1,379 @@ +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 default set runs (the catalog's + DefaultSelected flags, which the instance-wide settings can change). 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 Redacted + Return the redacted rows (password replaced, copyField dropped) - for automation that forwards + the results into an alert payload. + .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. Loaded from CaseId when omitted. + .PARAMETER DeploymentId + Live-progress job id (New-CIPPAsyncDeployment, one row named after the UPN). When set, the + row gets one step per selected action, updated as each runs - how a background run started + by ExecBECRemediate reports back. The step messages carry the unredacted result text so the + operator can still copy a new password; everything else stored stays redacted. + .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, + [switch]$Redacted, + [string]$CaseId, + $RunResults, + [string]$DeploymentId, + $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; member access on either is case-insensitive. + $GetParam = { param($Name) $Parameters.$Name } + $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 }) + } + } + + if ($CaseId -and -not $RunResults) { + try { + $RunResults = (Get-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -IncludeResults).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' + } + } + + # Progress reporting never stops the containment: the helpers swallow their own write failures. + if ($DeploymentId) { + try { + $null = New-CIPPAsyncDeployment -JobId $DeploymentId -Names @($UserPrincipalName) -StepTitles @($Selected.Label) -Source 'BECRemediation' -TenantFilter $TenantFilter + Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $UserPrincipalName -Status 'running' + } catch { + Write-Information "BEC containment: could not create the progress row $DeploymentId`: $($_.Exception.Message)" + } + } + $Finished = $false + + 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)" + } + } + + for ($StepIndex = 0; $StepIndex -lt $Selected.Count; $StepIndex++) { + $Action = $Selected[$StepIndex] + $Id = $Action.Id + $RowsBefore = $Rows.Count + if ($DeploymentId) { Set-CIPPAsyncDeploymentStep -JobId $DeploymentId -Name $UserPrincipalName -StepIndex $StepIndex -StepStatus 'running' -Message 'In progress' } + 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 '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 { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SpId" -tenantid $TenantFilter -type PATCH -body '{"accountEnabled":false}' -AsApp $true + & $Add $Id $SpId 'success' "Disabled service principal $SpId tenant-wide" $null + } catch { & $Add $Id $SpId 'error' "Failed to disable service principal $SpId`: $((Get-CippException -Exception $_).NormalizedError)" $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 { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Disable-TransportRule' -cmdParams @{ Identity = $RuleId; Confirm = $false } -useSystemMailbox $true + & $Add $Id $RuleId 'success' "Disabled transport rule '$RuleId'" $null + } catch { & $Add $Id $RuleId 'error' "Failed to disable transport rule '$RuleId': $((Get-CippException -Exception $_).NormalizedError)" $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 { + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Disable-App' -cmdParams @{ Identity = $AddInId; Mailbox = $UserPrincipalName; Confirm = $false } -Anchor $UserPrincipalName + & $Add $Id $AddInId 'success' "Disabled add-in $AddInId for $UserPrincipalName" $null + } catch { & $Add $Id $AddInId 'error' "Failed to disable add-in $AddInId`: $((Get-CippException -Exception $_).NormalizedError)" $null } + } + } + 'BlockProtocols' { + $Protocols = @((& $AsList (& $GetParam 'Protocols')) | Select-Object -Unique) + if ($Protocols.Count -eq 0) { $Protocols = @('EWS', 'IMAP', 'POP', 'ActiveSync', 'SmtpAuth') } + # Set-CASMailbox switch per protocol; SmtpAuth maps to SmtpClientAuthenticationDisabled, which is inverted. + $ProtocolMap = @{ EWS = 'EWSEnabled'; IMAP = 'IMAPEnabled'; POP = 'POPEnabled'; ActiveSync = 'ActiveSyncEnabled'; OWA = 'OWAEnabled'; MAPI = 'MAPIEnabled'; ECP = 'ECPEnabled'; SmtpAuth = 'SmtpClientAuthenticationDisabled' } + $Unknown = @($Protocols | Where-Object { -not $ProtocolMap.ContainsKey([string]$_) }) + if ($Unknown.Count -gt 0) { throw "Unknown protocol(s): $($Unknown -join ', ')" } + $CasParams = @{ Identity = $UserPrincipalName } + foreach ($Protocol in $Protocols) { $CasParams[$ProtocolMap[[string]$Protocol]] = ([string]$Protocol -eq 'SmtpAuth') } + $null = New-ExoRequest -tenantid $TenantFilter -cmdlet 'Set-CASMailbox' -cmdParams $CasParams -Anchor $UserPrincipalName + & $Add $Id $UserPrincipalName 'success' "Disabled $($Protocols -join ', ') for $UserPrincipalName" $null + } + { $_ -in @('BlockMobileDevices', 'RemoveMobileDevices') } { + $Picked = @((& $GetParam 'MobileDeviceIds') | Where-Object { $_ } | ForEach-Object { [string]$_ }) + $Known = @($RunResults.SuspectUserDevices | Where-Object { $_ }) + # The block list takes the DeviceID and the removal takes the partnership Guid, so a pick the run's + # inventory does not know (no run, or an API caller) is looked up on the mailbox rather than guessed. + $Unresolved = @($Picked | Where-Object { $_ -notin @($Known.DeviceID) -and $_ -notin @($Known.Guid) -and $_ -notin @($Known.Identity) }) + if ($Known.Count -eq 0 -or $Unresolved.Count -gt 0) { + $Live = @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-MobileDevice' -cmdParams @{ Mailbox = $UserPrincipalName } -Anchor $UserPrincipalName | Where-Object { $_ }) + $Known = @($Known) + @($Live | Where-Object { $_.Guid -notin @($Known.Guid) }) + } + $DeviceRows = if ($Picked.Count -gt 0) { @($Known | Where-Object { $_.DeviceID -in $Picked -or $_.Guid -in $Picked -or $_.Identity -in $Picked }) } else { @($Known) } + foreach ($Missing in @($Picked | Where-Object { $_ -notin @($DeviceRows.DeviceID) -and $_ -notin @($DeviceRows.Guid) -and $_ -notin @($DeviceRows.Identity) })) { + & $Add $Id $Missing 'error' "No mobile device partnership '$Missing' found on $UserPrincipalName" $null + } + if ($DeviceRows.Count -eq 0) { if ($Picked.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 } + $DeviceAction = if ($Id -eq 'DisableRegisteredDevices') { 'Disable' } else { 'Delete' } + foreach ($DeviceId in $DeviceIds) { + try { + $R = Set-CIPPDeviceState -Action $DeviceAction -DeviceID $DeviceId -TenantFilter $TenantFilter -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 + } + if ($DeploymentId) { + $StepRows = @(for ($i = $RowsBefore; $i -lt $Rows.Count; $i++) { $Rows[$i] }) + $StepStatus = if (@($StepRows | Where-Object { $_.state -eq 'error' }).Count -gt 0) { 'failed' } else { 'succeeded' } + $StepMessage = if ($StepRows.Count -gt 0) { @($StepRows.resultText) -join "`n" } else { 'Done' } + Set-CIPPAsyncDeploymentStep -JobId $DeploymentId -Name $UserPrincipalName -StepIndex $StepIndex -StepStatus $StepStatus -Message $StepMessage + } + } + + # Persist and log a redacted copy: the password (copyField) must never reach the logbook or the run. + $RedactedRows = @(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 @($RedactedRows) + 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 { $_ }) + @([pscustomobject]@{ At = (Get-Date).ToUniversalTime().ToString('o'); By = [string]$By; Actions = @($Selected.Id); Results = $RedactedRows }) + $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)" + } + } + if ($DeploymentId) { + $OverallStatus = if (@($Rows | Where-Object { $_.state -eq 'error' }).Count -gt 0) { 'failed' } else { 'succeeded' } + Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $UserPrincipalName -Status $OverallStatus + } + $Finished = $true + } finally { + # an unexpected throw must not leave the progress view spinning + if ($DeploymentId -and -not $Finished) { Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $UserPrincipalName -Status 'failed' } + Set-CippBecCaseContext -CaseId $null + } + return $(if ($Redacted) { $RedactedRows } else { $Rows.ToArray() }) +} diff --git a/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPAnalysis.ps1 b/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPAnalysis.ps1 new file mode 100644 index 0000000000000..610eb6e9403dc --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPAnalysis.ps1 @@ -0,0 +1,165 @@ +function Invoke-CIPPBecIPAnalysis { + <# + .SYNOPSIS + Establishes which addresses behind a BEC case belong to the attacker and which to the user. + .DESCRIPTION + Shared by the investigation run and the IP review re-run: + 1. Guidance: CIPP's IP allow/block list, trusted named locations and the Exchange IP lists + (always re-read, so a review that adds to the CIPP list takes effect). + 2. Baseline: where the user signed in from before the window (re-used when passed in). + 3. Geo for every address (cached by the geo helper), so hosting and proxy networks are known. + 4. A first verdict pass, then the tenant-wide "who else signed in from here" lookup for every + address that is not already settled (Safe, Service or LikelyUser) - re-using peers already + looked up for this case - and, with -SampleColleagues, the sign-ins of a random sample of + recently active colleagues across the case's addresses (shared offices and VPNs show up + there), then the final verdict pass with all of it included. + Returns { Baseline, Guidance, Peers, PeersResult, Geo, Verdicts, Events } where Baseline, + Guidance and PeersResult are collector results for the completeness markers. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER UserId + The investigated user's object id. + .PARAMETER UserPrincipalName + The investigated user. + .PARAMETER Results + The case payload so far (sign-ins, change sections, sent mail, mail activity, rules). + .PARAMETER Heuristics + The BEC heuristics object. + .PARAMETER WindowStart + Start of the investigation window (UTC). + .PARAMETER UsageLocation + The user's usage location. + .PARAMETER Anchor + Anchor mailbox for Exchange requests. + .PARAMETER Baseline + A baseline profile from an earlier run of this case; fetched when omitted. + .PARAMETER KnownPeers + Peer rows from an earlier run of this case, re-used instead of looked up again. + .PARAMETER Overrides + This case's investigator overrides. + .PARAMETER ExtraPeers + Hashtable of peer evidence gathered for users the investigator chose to correlate, merged in. + .PARAMETER TechnicianIPs + The addresses of the technicians who ran or reviewed the case ({ IP, By }). + .PARAMETER SampleColleagues + Correlate a random sample of recently active colleagues (the first run; a review re-uses the + peers stored on the case, which already include them). + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$UserId, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true)]$Results, + [Parameter(Mandatory = $true)]$Heuristics, + [Parameter(Mandatory = $true)][datetime]$WindowStart, + [string]$UsageLocation, + [string]$Anchor, + $Baseline, + [object[]]$KnownPeers = @(), + [object[]]$Overrides = @(), + [hashtable]$ExtraPeers = @{}, + [object[]]$TechnicianIPs = @(), + [switch]$SampleColleagues + ) + + $BaselineDays = [int]($Heuristics.baseline.days ?? 30) + $BaselineStart = $WindowStart.ToUniversalTime().AddDays(-$BaselineDays) + + $Guidance = try { Get-CIPPBecIPGuidance -TenantFilter $TenantFilter -Anchor $Anchor } catch { New-CIPPBecCollectorResult -Data @() -Error "IP guidance failed: $($_.Exception.Message)" } + + $BaselineResult = if ($Baseline) { + New-CIPPBecCollectorResult -Data $Baseline -Count ([int]$Baseline.Successful) + } else { + try { Get-CIPPBecSignInBaseline -TenantFilter $TenantFilter -UserId $UserId -StartDate $BaselineStart -EndDate $WindowStart } catch { + New-CIPPBecCollectorResult -Data $null -Error "Sign-in baseline failed: $((Get-NormalizedError -message $_.Exception.Message))" + } + } + + $Events = @(ConvertTo-CIPPBecIPEvents -Results $Results -UserPrincipalName $UserPrincipalName) + $SignIns = @($Results.SuspectUserSignIns | Where-Object { $_ }) + $NonInteractive = @($Results.NonInteractiveSignIns | Where-Object { $_ }) + + $AllIPs = @( + @($SignIns.IPAddress) + @($NonInteractive.IPAddress) + @($Events.IP) | + Where-Object { $_ } | ForEach-Object { ConvertTo-CIPPBecHostAddress -Address ([string]$_) } | Where-Object { $_ } | Select-Object -Unique + ) + $Geo = @{} + if ($AllIPs.Count -gt 0) { + try { $Geo = Get-CIPPGeoIPLocationBatch -IPs $AllIPs } catch { Write-Information "BEC IP analysis: geo lookup failed: $($_.Exception.Message)" } + } + + $VerdictParams = @{ + SignIns = $SignIns + NonInteractiveSignIns = $NonInteractive + Events = $Events + Baseline = $BaselineResult.Data + Guidance = @($Guidance.Data) + Overrides = @($Overrides) + Geo = $Geo + UsageLocation = $UsageLocation + Heuristics = $Heuristics + TechnicianIPs = @($TechnicianIPs) + } + $Preliminary = @(Get-CIPPBecIPVerdicts @VerdictParams -Peers $ExtraPeers) + + $Peers = @{} + foreach ($Peer in @($KnownPeers | Where-Object { $_ -and $_.IP })) { $Peers[[string]$Peer.IP] = $Peer } + $ToLookUp = @($Preliminary | Where-Object { $_.Verdict -notin @('Safe', 'Service', 'LikelyUser') -and -not $Peers.ContainsKey($_.IP) } | ForEach-Object { $_.IP }) + $PeerError = $null + if ($ToLookUp.Count -gt 0) { + try { + $Found = Get-CIPPBecIPPeers -TenantFilter $TenantFilter -UserId $UserId -IPs $ToLookUp -StartDate $BaselineStart -WindowStart $WindowStart + foreach ($Key in $Found.Keys) { $Peers[$Key] = $Found[$Key] } + $Failed = @($Found.Values | Where-Object { $_.Error }) + if ($Failed.Count -gt 0) { $PeerError = "$($Failed.Count) address lookup(s) failed: $($Failed[0].Error)" } + } catch { + $PeerError = "Other-account lookup failed: $((Get-NormalizedError -message $_.Exception.Message))" + } + } + if ($SampleColleagues) { + try { + $Colleagues = @(Get-CIPPBecColleagueSample -TenantFilter $TenantFilter -ExcludeUserId $UserId -StartDate $WindowStart -Count ([int]($Heuristics.baseline.colleagueSample ?? 8))) + if ($Colleagues.Count -gt 0) { + $Sampled = Get-CIPPBecCorrelatedUserPeers -TenantFilter $TenantFilter -UserIds $Colleagues -IPs @($Preliminary.IP) -StartDate $BaselineStart -WindowStart $WindowStart + foreach ($Key in $Sampled.Keys) { + if ($ExtraPeers.ContainsKey($Key)) { continue } + $ExtraPeers[$Key] = $Sampled[$Key] + } + } + } catch { + $PeerError = (@($PeerError, "Colleague sample failed: $((Get-NormalizedError -message $_.Exception.Message))") | Where-Object { $_ }) -join '; ' + } + } + # Correlated users (chosen, or sampled above) add to - never replace - the tenant-wide evidence + foreach ($Key in $ExtraPeers.Keys) { + $Extra = $ExtraPeers[$Key] + if (-not $Peers.ContainsKey($Key)) { $Peers[$Key] = $Extra; continue } + $Merged = $Peers[$Key] + $Users = @(@($Merged.Users) + @($Extra.Users) | Where-Object { $_ } | Select-Object -Unique) + $Peers[$Key] = [pscustomobject]@{ + IP = $Key + OtherUsers = $Users.Count + # the same colleague can be found by both lookups: take the larger count, never the sum + OtherUsersBefore = [Math]::Max([int]$Merged.OtherUsersBefore, [int]$Extra.OtherUsersBefore) + OtherUsersInWindowOnly = [Math]::Max([int]$Merged.OtherUsersInWindowOnly, [int]$Extra.OtherUsersInWindowOnly) + Users = $Users + Accounts = @($Merged.Accounts) + Sampled = [bool]$Merged.Sampled + Error = $Merged.Error + } + } + $Verdicts = @(Get-CIPPBecIPVerdicts @VerdictParams -Peers $Peers) + + [pscustomobject]@{ + Baseline = $BaselineResult + Guidance = $Guidance + Peers = $Peers + PeersResult = New-CIPPBecCollectorResult -Data @($Peers.Values) -Error $PeerError + Geo = $Geo + Verdicts = $Verdicts + Events = $Events + } +} diff --git a/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPReview.ps1 b/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPReview.ps1 new file mode 100644 index 0000000000000..f80c9515d2bf6 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Invoke-CIPPBecIPReview.ps1 @@ -0,0 +1,192 @@ +function Invoke-CIPPBecIPReview { + <# + .SYNOPSIS + Re-judges a BEC case's IP addresses with the investigator's input and re-reads what depends on them. + .DESCRIPTION + The background half of ExecBECIPReview. For a completed case it keeps the original window and + everything that does not depend on the verdicts, and replaces what does: + - the investigator's overrides (Safe / Compromised per address or CIDR range) become the case's + IPOverrides and decide those addresses outright; + - chosen accounts are correlated (Get-CIPPBecCorrelatedUserPeers) and added to the peers; + - the address lists are re-read, so an entry just added to CIPP's IP allow/block list counts; + - mailbox activity is re-read (its raw records feed the detail pass), the verdicts are + re-judged with the stored baseline and peers, and the attacker activity and delegated + mailboxes are re-collected; + - the rows are re-stamped, the score recomputed, and the review appended to IPReviewHistory. + Progress is reported per step on the DeploymentId row, like containment. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER CaseId + The BEC case (run) to review. + .PARAMETER Overrides + { IP (address or CIDR range), Verdict (Safe|Compromised), Note } - the complete set for the case. + .PARAMETER CorrelateUserIds + Object ids of accounts whose sign-ins to correlate with the case's addresses. + .PARAMETER DeploymentId + Live-progress job id. + .PARAMETER Headers + The requesting user's headers (for logging and the review history). + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$CaseId, + [object[]]$Overrides = @(), + [string[]]$CorrelateUserIds = @(), + [string]$DeploymentId, + $Headers, + [string]$APIName = 'BECIPReview' + ) + + $StepTitles = @('Loading the case', 'Correlating the chosen accounts', 'Re-reading mailbox activity', 'Re-judging the IP addresses', 'Re-reading attacker activity and delegated mailboxes', 'Scoring and saving') + $Name = $CaseId + $Step = { param($Index, $Status, $Message) if ($DeploymentId) { Set-CIPPAsyncDeploymentStep -JobId $DeploymentId -Name $Name -StepIndex $Index -StepStatus $Status -Message ([string]$Message) } } + if ($DeploymentId) { + try { + $null = New-CIPPAsyncDeployment -JobId $DeploymentId -Names @($Name) -StepTitles $StepTitles -Source 'BECIPReview' -TenantFilter $TenantFilter + Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $Name -Status 'running' + } catch { Write-Information "BEC IP review: progress unavailable: $($_.Exception.Message)" } + } + if (-not $PSCmdlet.ShouldProcess("$TenantFilter/$CaseId", 'Re-judge the case IP addresses')) { return } + + $Current = 0 + try { + & $Step 0 'running' 'In progress' + $Heuristics = Get-CIPPBecHeuristics + $Run = Get-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -IncludeResults + if (-not $Run -or $Run.Status -ne 'Completed' -or -not $Run.Results) { throw "Case $CaseId is not a completed investigation in $TenantFilter" } + $Results = $Run.Results + $UserName = [string]($Run.UserPrincipalName ?? $Results.UserPrincipalName) + $UserId = [string]$Run.UserId + $WindowDays = [int]($Results.AnalysisWindowDays ?? $Heuristics.window.days ?? 7) + $EndDate = ([datetime]$Results.ExtractedAt).ToUniversalTime() + $StartDate = $EndDate.AddDays(-$WindowDays) + $BaselineStart = $StartDate.AddDays(-[int]($Heuristics.baseline.days ?? 30)) + $UsageLocation = [string]$Results.LocationAnalysis.UsageLocation + # the technicians who ran or reviewed the case: the reviewer's own address (first x-forwarded-for + # hop of the stored request headers) joins the ones already on the case + $ReviewerIP = ConvertTo-CIPPBecHostAddress -Address ([string](([string]$Headers.'x-forwarded-for' -split ',')[0])).Trim() + $ReviewerName = 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 { $null } } else { $null } + $TechnicianIPs = @( + @($Results.IPTechnicians | Where-Object { $_ -and $_.IP }) + if ($ReviewerIP -and $ReviewerIP -notin @($Results.IPTechnicians.IP)) { [pscustomobject]@{ IP = $ReviewerIP; By = [string]$ReviewerName } } + ) + $CaseOverrides = @(foreach ($Override in @($Overrides | Where-Object { $_ })) { + $Verdict = [string]($Override.Verdict.value ?? $Override.Verdict) + if ($Verdict -notin @('Safe', 'Compromised')) { continue } + [pscustomobject]@{ Range = ConvertTo-CIPPIPRange -Value ([string]($Override.IP ?? $Override.Range)); Verdict = $Verdict; Note = [string]$Override.Note } + }) + $Completeness = $Results.Completeness + if (-not $Completeness) { $Completeness = [pscustomobject]@{}; $Results | Add-Member -NotePropertyName 'Completeness' -NotePropertyValue $Completeness -Force } + $Mark = { + param($MarkerName, $Result) + $Info = if ($Result.Error) { Get-CIPPBecErrorInfo -Message ([string]$Result.Error) } else { $null } + $Completeness | Add-Member -NotePropertyName $MarkerName -NotePropertyValue ([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 + }) -Force + } + $Set = { param($Property, $Value) $Results | Add-Member -NotePropertyName $Property -NotePropertyValue $Value -Force } + & $Step 0 'succeeded' "Case $CaseId for $UserName" + + $Current = 1 + & $Step 1 'running' 'In progress' + $ExtraPeers = @{} + if (@($CorrelateUserIds | Where-Object { $_ }).Count -gt 0) { + $CaseIPs = @(@($Results.IPVerdicts.IP) + @($Results.SuspectUserSignIns.IPAddress) + @($Results.NonInteractiveSignIns.IPAddress) | Where-Object { $_ } | ForEach-Object { ConvertTo-CIPPBecHostAddress -Address ([string]$_) } | Where-Object { $_ } | Select-Object -Unique) + $ExtraPeers = Get-CIPPBecCorrelatedUserPeers -TenantFilter $TenantFilter -UserIds $CorrelateUserIds -IPs $CaseIPs -StartDate $BaselineStart -WindowStart $StartDate + & $Step 1 'succeeded' "$(@($CorrelateUserIds).Count) account(s) correlated; $($ExtraPeers.Count) of the case's addresses shared" + } else { + & $Step 1 'succeeded' 'No accounts chosen' + } + + $Current = 2 + & $Step 2 'running' 'In progress' + $Activity = try { Get-CIPPBecMailActivity -TenantFilter $TenantFilter -UserPrincipalName $UserName -StartDate $StartDate -EndDate $EndDate -Heuristics $Heuristics -Anchor $UserName } catch { + New-CIPPBecCollectorResult -Data @() -Error "Mailbox activity failed: $((Get-NormalizedError -message $_.Exception.Message))" + } + & $Mark 'MailActivity' $Activity + & $Set 'MailActivity' @($Activity.Data) + & $Set 'MailActivitySummary' $Activity.Summary + $MailRecords = @($Activity.Records | Where-Object { $_ }) + & $Step 2 'succeeded' "$(@($Activity.Data).Count) activity group(s), $($MailRecords.Count) record(s)" + + $Current = 3 + & $Step 3 'running' 'In progress' + $Analysis = Invoke-CIPPBecIPAnalysis -TenantFilter $TenantFilter -UserId $UserId -UserPrincipalName $UserName -Results $Results -Heuristics $Heuristics -WindowStart $StartDate -UsageLocation $UsageLocation -Anchor $UserName -Baseline $Results.IPBaseline -KnownPeers @($Results.IPPeers) -Overrides $CaseOverrides -ExtraPeers $ExtraPeers -TechnicianIPs $TechnicianIPs + & $Mark 'SignInBaseline' $Analysis.Baseline + & $Mark 'IPGuidance' $Analysis.Guidance + & $Mark 'IPPeers' $Analysis.PeersResult + & $Mark 'IPVerdicts' ([pscustomobject]@{ Complete = $true; Count = @($Analysis.Verdicts).Count }) + $Verdicts = @($Analysis.Verdicts) + & $Set 'IPVerdicts' $Verdicts + & $Set 'IPGuidance' @($Analysis.Guidance.Data) + & $Set 'IPPeers' @($Analysis.Peers.Values) + & $Set 'IPOverrides' @($CaseOverrides) + & $Set 'IPTechnicians' @($TechnicianIPs) + Set-CIPPBecIPVerdictStamp -Results $Results -Verdicts $Verdicts + $Attackers = @($Verdicts | Where-Object { $_.Verdict -in @('Compromised', 'LikelyAttacker') }).Count + & $Step 3 'succeeded' "$($Verdicts.Count) address(es): $Attackers attacker, $(@($Verdicts | Where-Object { $_.Verdict -in @('Suspicious', 'Unknown') }).Count) suspicious or unknown" + + $Current = 4 + & $Step 4 'running' 'In progress' + $Attacker = Get-CIPPBecAttackerActivity -TenantFilter $TenantFilter -UserPrincipalName $UserName -StartDate $StartDate -EndDate $EndDate -Heuristics $Heuristics -Verdicts $Verdicts -SignIns @($Results.SuspectUserSignIns) -NonInteractiveSignIns @($Results.NonInteractiveSignIns) -MailRecords $MailRecords -SharingChanges @($Results.SharingChanges) -Anchor $UserName + & $Mark 'AttackerMailActivity' $Attacker.Mail + & $Mark 'AttackerFileActivity' $Attacker.Files + & $Mark 'LinkUsage' $Attacker.LinkUsage + & $Mark 'FormsActivity' $Attacker.Forms + & $Set 'AttackerMailActivity' @($Attacker.Mail.Data) + & $Set 'AttackerMailSummary' $Attacker.Mail.Summary + & $Set 'AttackerFileActivity' @($Attacker.Files.Data) + & $Set 'AttackerFileSummary' $Attacker.Files.Summary + & $Set 'LinkUsage' @($Attacker.LinkUsage.Data) + & $Set 'FormsActivity' @($Attacker.Forms.Data) + & $Set 'FormsSummary' $Attacker.Forms.Summary + $Delegated = Get-CIPPBecDelegatedAccess -TenantFilter $TenantFilter -UserPrincipalName $UserName -UserDisplayName ([string]$Run.DisplayName) -PermissionChanges @($Results.MailboxPermissionChanges) -MailActivity @($Activity.Data) -AttackerMail @($Attacker.Mail.Data) + & $Mark 'DelegatedAccess' $Delegated + & $Set 'DelegatedAccess' @($Delegated.Data) + $Blast = Get-CIPPBecBlastRadius -TenantFilter $TenantFilter -UserId $UserId -UserPrincipalName $UserName -Verdicts $Verdicts -Peers $Analysis.Peers -StartDate $StartDate -EndDate $EndDate -Heuristics $Heuristics -Anchor $UserName + & $Mark 'BlastRadius' $Blast + & $Set 'BlastRadius' @($Blast.Data) + & $Step 4 'succeeded' "$(@($Attacker.Mail.Data).Count) mail row(s), $(@($Attacker.Files.Data).Count) file row(s), $(@($Attacker.Forms.Data).Count) Forms action(s), $(@($Delegated.Data).Count) delegated mailbox(es), $(@($Blast.Data | Where-Object { $_.Reached }).Count) other account(s) reached" + + $Current = 5 + & $Step 5 'running' 'In progress' + $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' } } else { 'CIPP' } + $History = @(@($Results.IPReviewHistory | Where-Object { $_ }) + @([pscustomobject]@{ + At = (Get-Date).ToUniversalTime().ToString('o') + By = [string]$By + Overrides = @($CaseOverrides) + CorrelatedUsers = @($CorrelateUserIds | Where-Object { $_ }) + AttackerIPs = $Attackers + })) + & $Set 'IPReviewHistory' $History + & $Set 'UserPrincipalName' $UserName + $Score = Get-CIPPBecScore -Results $Results -Heuristics $Heuristics + & $Set 'Score' $Score + $null = Set-CIPPBecReport -TenantFilter $TenantFilter -CaseId $CaseId -Results $Results -Properties @{ + Score = [int]$Score.Value + Level = [string]$Score.Level + LastIPReviewAt = (Get-Date).ToUniversalTime().ToString('o') + IncompleteCount = @($Completeness.PSObject.Properties | Where-Object { -not $_.Value.Complete }).Count + } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Reviewed the IP addresses of BEC case $CaseId for $UserName`: $Attackers attacker address(es), threat level $($Score.Level) ($($Score.Value))" -sev 'Info' + & $Step 5 'succeeded' "Threat level $($Score.Level) ($($Score.Value))" + if ($DeploymentId) { Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $Name -Status 'succeeded' } + return "Reviewed the IP addresses of case $CaseId`: $Attackers attacker address(es), threat level $($Score.Level) ($($Score.Value))" + } catch { + $Message = Get-NormalizedError -message $_.Exception.Message + & $Step $Current 'failed' $Message + if ($DeploymentId) { Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $Name -Status 'failed' } + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "BEC IP review of case $CaseId failed: $Message" -sev 'Error' + throw + } +} diff --git a/Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1 b/Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1 new file mode 100644 index 0000000000000..d159ba163feff --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/New-CIPPBecCaseId.ps1 @@ -0,0 +1,17 @@ +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 + row, every logbook entry written during the case and the evidence package. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + return 'BEC-{0}-{1}' -f (Get-Date).ToUniversalTime().ToString('yyyyMMddHHmmss'), [guid]::NewGuid().ToString('N').Substring(0, 6) +} diff --git a/Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1 b/Modules/CIPPCore/Public/BEC/New-CIPPBecCollectorResult.ps1 new file mode 100644 index 0000000000000..a0f24a797c0c0 --- /dev/null +++ b/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 = @($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/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 b/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 new file mode 100644 index 0000000000000..76f7fe6a58763 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/New-CIPPBecEvidencePackage.ps1 @@ -0,0 +1,142 @@ +function New-CIPPBecEvidencePackage { + <# + .SYNOPSIS + Builds 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 score, the containment history, every logbook line stamped with the + case id, and the full and C-suite-summary report PDFs rendered server-side. Nothing is stored: + the ZIP is returned to the caller to hand to the browser. 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 Headers + CIPP request headers, for logging. + .PARAMETER APIName + Logging API name. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$CaseId, + $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 + $GeneratedUtc = (Get-Date).ToUniversalTime() + $Utf8 = [System.Text.UTF8Encoding]::new($false) + + # 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', '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)) + + # Render the report PDFs server-side (the full report and the C-suite summary) through the same + # builder and kit the report endpoint uses. The builder reads containment off becData.Run, so + # attach the run block exactly as ExecGetBecReportPdf does. A render failure must not lose the rest + # of the evidence, so it is caught and the package ships without the PDFs. + try { + $BecData = $Results + $RunBlock = [pscustomobject]@{ + CaseId = $Run.CaseId + Status = $Run.Status + ExtractedAt = $Run.ExtractedAt + RequestedAt = $Run.RequestedAt + RequestedBy = $Run.RequestedBy + Containment = $Run.Containment + } + $BecData | Add-Member -NotePropertyName 'Run' -NotePropertyValue $RunBlock -Force + $TenantName = Get-CippReportTenantName -TenantFilter $TenantFilter + $DisplayName = @($Run.DisplayName, $Run.UserPrincipalName, $Run.UserId) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 + $UserData = [pscustomobject]@{ displayName = $DisplayName; userPrincipalName = $Run.UserPrincipalName; id = $Run.UserId } + $Full = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $TenantName + $Files['report-full.pdf'] = ConvertTo-CippReportPdf -Blocks $Full.Blocks -Variables $Full.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report' + $Summary = Build-CippBecReportTree -UserData $UserData -BecData $BecData -TenantName $TenantName -Variant summary + $Files['report-summary.pdf'] = ConvertTo-CippReportPdf -Blocks $Summary.Blocks -Variables $Summary.Variables -TenantName $TenantName -TenantFilter $TenantFilter -ReportName 'BEC Analysis Report' + } catch { + Write-Information "BEC evidence: server-side PDF render failed for $CaseId`: $($_.Exception.Message)" + } + + $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() + + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Exported the evidence package for BEC case $CaseId ($($Files.Count) files, $([math]::Round($ZipBytes.Length / 1KB)) KB); the package was handed to the requester and not stored" -Sev 'Info' + + return [pscustomobject]@{ + CaseId = $CaseId + Bytes = $ZipBytes.Length + FileCount = $Files.Count + ZipBytes = $ZipBytes + } +} diff --git a/Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1 b/Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1 new file mode 100644 index 0000000000000..523cc3f37bdec --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/New-CIPPBecRunRequest.ps1 @@ -0,0 +1,89 @@ +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). + .PARAMETER RequestedFromIP + The requesting technician's address (first x-forwarded-for hop). The run treats it as the + technician's, never the user's or the attacker's. + .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, + [string]$RequestedFromIP + ) + + # 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' + RequestedBy = $RequestedBy + RequestedAt = (Get-Date).ToUniversalTime().ToString('o') + } + if ($DisplayName) { $Properties.DisplayName = $DisplayName } + if ($QueueId) { $Properties.QueueId = $QueueId } + if ($RequestedFromIP) { $Properties.RequestedFromIP = $RequestedFromIP } + $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' -TenantFilter $TenantFilter + } + + $Item = @{ + FunctionName = 'BECRun' + UserID = $UserId + TenantFilter = $TenantFilter + userName = [string]$UserPrincipalName + CaseId = $CaseId + } + if ($RequestedFromIP) { $Item.RequestedFromIP = $RequestedFromIP; $Item.RequestedBy = $RequestedBy } + if ($QueueId) { + $Item.QueueId = $QueueId + $Item.QueueName = "BEC investigation $Name" + } + + return [pscustomobject]@{ + CaseId = $CaseId + Item = $Item + } +} diff --git a/Modules/CIPPCore/Public/BEC/New-CIPPBecTargetedCAPolicy.ps1 b/Modules/CIPPCore/Public/BEC/New-CIPPBecTargetedCAPolicy.ps1 new file mode 100644 index 0000000000000..d228286e48dbe --- /dev/null +++ b/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 = "$Message; removal scheduled for $($ExpiresUtc.ToString('u'))" + } catch { + $Message = "$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/Modules/CIPPCore/Public/BEC/Remove-CIPPBecReport.ps1 b/Modules/CIPPCore/Public/BEC/Remove-CIPPBecReport.ps1 new file mode 100644 index 0000000000000..6c678758285b4 --- /dev/null +++ b/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/Modules/CIPPCore/Public/BEC/Remove-CIPPBecSharingLinks.ps1 b/Modules/CIPPCore/Public/BEC/Remove-CIPPBecSharingLinks.ps1 new file mode 100644 index 0000000000000..7d8b7aa0c11de --- /dev/null +++ b/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/Modules/CIPPCore/Public/BEC/Remove-CIPPBecTargetedCAPolicy.ps1 b/Modules/CIPPCore/Public/BEC/Remove-CIPPBecTargetedCAPolicy.ps1 new file mode 100644 index 0000000000000..a984c8cf6173b --- /dev/null +++ b/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/Modules/CIPPCore/Public/BEC/Remove-CIPPMailboxDelegation.ps1 b/Modules/CIPPCore/Public/BEC/Remove-CIPPMailboxDelegation.ps1 new file mode 100644 index 0000000000000..7f2601b0fac94 --- /dev/null +++ b/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/Modules/CIPPCore/Public/BEC/Remove-CIPPUserOAuthGrant.ps1 b/Modules/CIPPCore/Public/BEC/Remove-CIPPUserOAuthGrant.ps1 new file mode 100644 index 0000000000000..5510e27117a05 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Remove-CIPPUserOAuthGrant.ps1 @@ -0,0 +1,59 @@ +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 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' + ) + + $Sets = @( + @{ Ids = $GrantIds; Uri = 'oauth2PermissionGrants/{0}'; Noun = 'consent grant'; NeedsUser = $false } + @{ Ids = $AppRoleAssignmentIds; Uri = "users/$UserId/appRoleAssignments/{0}"; Noun = 'app-role assignment'; NeedsUser = $true } + ) + $Results = [System.Collections.Generic.List[object]]::new() + foreach ($Set in $Sets) { + foreach ($Id in @($Set.Ids | Where-Object { $_ })) { + if ($Set.NeedsUser -and -not $UserId) { + $Results.Add([pscustomobject]@{ Target = $Id; state = 'error'; resultText = "The user object id is required to remove an $($Set.Noun)" }) + continue + } + if (-not $PSCmdlet.ShouldProcess($Id, "Delete $($Set.Noun)")) { continue } + try { + $null = New-GraphPOSTRequest -uri "https://graph.microsoft.com/v1.0/$($Set.Uri -f $Id)" -tenantid $TenantFilter -type DELETE -AsApp $true + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Deleted $($Set.Noun) $Id" -Sev 'Info' + $Results.Add([pscustomobject]@{ Target = $Id; state = 'success'; resultText = "Deleted $($Set.Noun) $Id" }) + } catch { + $ErrorMessage = Get-CippException -Exception $_ + Write-LogMessage -headers $Headers -API $APIName -tenant $TenantFilter -message "Failed to delete $($Set.Noun) $Id`: $($ErrorMessage.NormalizedError)" -Sev 'Error' -LogData $ErrorMessage + $Results.Add([pscustomobject]@{ Target = $Id; state = 'error'; resultText = "Failed to delete $($Set.Noun) $Id`: $($ErrorMessage.NormalizedError)" }) + } + } + } + return $Results.ToArray() +} diff --git a/Modules/CIPPCore/Public/BEC/Resolve-CIPPBecMessageSubjects.ps1 b/Modules/CIPPCore/Public/BEC/Resolve-CIPPBecMessageSubjects.ps1 new file mode 100644 index 0000000000000..f4daf9e77e868 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Resolve-CIPPBecMessageSubjects.ps1 @@ -0,0 +1,83 @@ +function Resolve-CIPPBecMessageSubjects { + <# + .SYNOPSIS + Names the messages an attacker touched, by internet message id, from the message trace. + .DESCRIPTION + Audit records for opened (bound) messages carry only the internet message id. Subjects already + known from the run's own sent and received traces are used first; the rest are looked up with + Get-MessageTraceV2 -MessageId in batches, walking back in 10-day slices (the cmdlet's limit) + up to LookbackDays (the trace keeps 90 days), newest slice first, stopping as soon as every id + is named. A message older than the trace keeps its id only. Returns { Subjects (hashtable + id -> subject), Resolved, Unresolved, Error }. Trace metadata only - never message content. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER MessageIds + The internet message ids to name. + .PARAMETER Known + Subjects already known (hashtable id -> subject), e.g. from the run's sent and received traces. + .PARAMETER LookbackDays + How far back to trace (at most 90). + .PARAMETER Anchor + Anchor mailbox for the Exchange requests. + .PARAMETER BatchSize + Message ids per trace call. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string[]]$MessageIds = @(), + [hashtable]$Known = @{}, + [ValidateRange(1, 90)][int]$LookbackDays = 90, + [string]$Anchor, + [ValidateRange(1, 500)][int]$BatchSize = 50 + ) + + $Subjects = @{} + $Pending = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($Id in @($MessageIds | Where-Object { $_ } | Select-Object -Unique)) { + $Key = [string]$Id + if ($Known.ContainsKey($Key) -and $Known[$Key]) { $Subjects[$Key] = [string]$Known[$Key] } else { $null = $Pending.Add($Key) } + } + + $ErrorText = $null + $End = (Get-Date).ToUniversalTime() + $Oldest = $End.AddDays(-$LookbackDays) + while ($Pending.Count -gt 0 -and $End -gt $Oldest) { + $Start = $End.AddDays(-10) + if ($Start -lt $Oldest) { $Start = $Oldest } + $Batch = @($Pending) + for ($i = 0; $i -lt $Batch.Count; $i += $BatchSize) { + $Chunk = @($Batch[$i..([Math]::Min($i + $BatchSize - 1, $Batch.Count - 1))]) + $ExoParams = @{ + tenantid = $TenantFilter + cmdlet = 'Get-MessageTraceV2' + # an array: the cmdlet silently matches nothing for a joined string + cmdParams = @{ MessageId = $Chunk; StartDate = $Start.ToString('s'); EndDate = $End.ToString('s'); ResultSize = 5000 } + } + if ($Anchor) { $ExoParams.Anchor = $Anchor } + try { + foreach ($Row in @(New-ExoRequest @ExoParams)) { + $Key = [string]$Row.MessageId + if ($Key -and $Pending.Contains($Key)) { + $Subjects[$Key] = [string]$Row.Subject + $null = $Pending.Remove($Key) + } + } + } catch { + $ErrorText = "Message trace lookup failed: $((Get-NormalizedError -message $_.Exception.Message))" + $Pending.Clear() + break + } + } + $End = $Start + } + + [pscustomobject]@{ + Subjects = $Subjects + Resolved = $Subjects.Count + Unresolved = @($MessageIds | Where-Object { $_ -and -not $Subjects.ContainsKey([string]$_) } | Select-Object -Unique).Count + Error = $ErrorText + } +} diff --git a/Modules/CIPPCore/Public/BEC/Search-CIPPBecAuditLog.ps1 b/Modules/CIPPCore/Public/BEC/Search-CIPPBecAuditLog.ps1 new file mode 100644 index 0000000000000..80dd51572b798 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Search-CIPPBecAuditLog.ps1 @@ -0,0 +1,178 @@ +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 IPAddresses + Optional client address filter. Takes bare addresses (a ':port' is stripped here - the service + rejects it) and matches records stored with a port. + .PARAMETER FreeText + Optional text the record must contain (e.g. a form id). + .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[]]$IPAddresses, + [string]$FreeText, + [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) } + if ($IPAddresses) { + $IPAddresses = @($IPAddresses | ForEach-Object { ConvertTo-CIPPBecHostAddress -Address ([string]$_) } | Where-Object { $_ } | Select-Object -Unique) + $SearchParam.IPAddresses = @($IPAddresses) + } + if ($FreeText) { $SearchParam.FreeText = $FreeText } + + $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 ($IPAddresses) { $Common.IPAddresses = $IPAddresses } + if ($FreeText) { $Common.FreeText = $FreeText } + 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/Modules/CIPPCore/Public/BEC/Set-CIPPBecIPVerdictStamp.ps1 b/Modules/CIPPCore/Public/BEC/Set-CIPPBecIPVerdictStamp.ps1 new file mode 100644 index 0000000000000..d1f296601f6c5 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Set-CIPPBecIPVerdictStamp.ps1 @@ -0,0 +1,44 @@ +function Set-CIPPBecIPVerdictStamp { + <# + .SYNOPSIS + Stamps each address's verdict onto the case rows that carry it. + .DESCRIPTION + Adds IPVerdict (Compromised, LikelyAttacker, Suspicious, Unknown, LikelyUser, Safe, Service) to + every sign-in, change, sent-mail and mailbox-activity row whose address has a verdict, so the + existing finding tables show which activity came from where. Rows are changed in place; + a row whose address has no verdict gets none. + .PARAMETER Results + The case payload (or the draft of it) whose rows are stamped. + .PARAMETER Verdicts + The IP verdict rows (Get-CIPPBecIPVerdicts). + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)]$Results, + [object[]]$Verdicts = @() + ) + + $ByIP = @{} + foreach ($Row in @($Verdicts | Where-Object { $_ -and $_.IP })) { $ByIP[[string]$Row.IP] = [string]$Row.Verdict } + if ($ByIP.Count -eq 0 -or -not $PSCmdlet.ShouldProcess('BEC case rows', 'Stamp IP verdicts')) { return } + $Sections = @( + @{ Name = 'SuspectUserSignIns'; Field = 'IPAddress' } + @{ Name = 'NonInteractiveSignIns'; Field = 'IPAddress' } + @{ Name = 'InboxRuleChanges'; Field = 'ClientIP' } + @{ Name = 'MailboxPermissionChanges'; Field = 'ClientIP' } + @{ Name = 'SafelistChanges'; Field = 'ClientIP' } + @{ Name = 'SharingChanges'; Field = 'ClientIP' } + @{ Name = 'TransportRuleChanges'; Field = 'ClientIP' } + @{ Name = 'DirectoryAudits'; Field = 'ClientIP' } + @{ Name = 'SentMessages'; Field = 'FromIP' } + @{ Name = 'MailActivity'; Field = 'ClientIP' } + ) + foreach ($Section in $Sections) { + foreach ($Row in @($Results.($Section.Name) | Where-Object { $_ })) { + $IP = ConvertTo-CIPPBecHostAddress -Address ([string]$Row.($Section.Field)) + if ($IP -and $ByIP.ContainsKey($IP)) { $Row | Add-Member -NotePropertyName 'IPVerdict' -NotePropertyValue $ByIP[$IP] -Force } + } + } +} diff --git a/Modules/CIPPCore/Public/BEC/Set-CIPPBecReport.ps1 b/Modules/CIPPCore/Public/BEC/Set-CIPPBecReport.ps1 new file mode 100644 index 0000000000000..e1d9cedf72351 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Set-CIPPBecReport.ps1 @@ -0,0 +1,72 @@ +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, 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 + } + } + + $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/Modules/CIPPCore/Public/BEC/Start-CIPPBecContainmentJob.ps1 b/Modules/CIPPCore/Public/BEC/Start-CIPPBecContainmentJob.ps1 new file mode 100644 index 0000000000000..fa7a904008504 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Start-CIPPBecContainmentJob.ps1 @@ -0,0 +1,53 @@ +function Start-CIPPBecContainmentJob { + <# + .SYNOPSIS + Queues BEC containment as a background scheduled task with live progress. + .DESCRIPTION + The background path of ExecBECRemediate, shaped like offboarding: creates the progress row + (so the caller can poll straight away), then queues Invoke-CIPPBecContainment as a run-now + scheduled task that fills the row with one step per action. The caller has already validated + the selection and the typed confirmation, so the task runs with -Confirmed. Its stored results + are the redacted rows; only the progress step messages carry a new password. + Returns the DeploymentId to poll with ListOffboardingProgress. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [string]$UserId, + [Parameter(Mandatory = $true)][string]$UserPrincipalName, + [Parameter(Mandatory = $true)][string[]]$Actions, + $Parameters, + [string]$CaseId, + $Headers, + [string]$APIName = 'BECRemediate' + ) + + if (-not $PSCmdlet.ShouldProcess($UserPrincipalName, "Queue BEC containment ($($Actions -join ', '))")) { return } + + $DeploymentId = New-CIPPAsyncDeployment -Names @($UserPrincipalName) -Source 'BECRemediation' -TenantFilter $TenantFilter + $Task = [pscustomobject]@{ + TenantFilter = $TenantFilter + Name = "BEC remediation: $UserPrincipalName" + Command = @{ value = 'Invoke-CIPPBecContainment' } + Parameters = [pscustomobject]@{ + UserId = $UserId + UserPrincipalName = $UserPrincipalName + Actions = @($Actions) + Parameters = $Parameters + Confirmed = $true + Redacted = $true + CaseId = $CaseId + DeploymentId = $DeploymentId + APIName = $APIName + } + } + # the scheduler reports a refused task as a returned 'Error - ...' string, not a throw + $Queued = Add-CIPPScheduledTask -Task $Task -hidden $false -RunNow -Headers $Headers + if ([string]$Queued -match '^(Error|Could not)') { + Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $UserPrincipalName -Status 'failed' + throw [string]$Queued + } + return $DeploymentId +} diff --git a/Modules/CIPPCore/Public/BEC/Start-CIPPBecIPReviewJob.ps1 b/Modules/CIPPCore/Public/BEC/Start-CIPPBecIPReviewJob.ps1 new file mode 100644 index 0000000000000..9f4c6ccae59a2 --- /dev/null +++ b/Modules/CIPPCore/Public/BEC/Start-CIPPBecIPReviewJob.ps1 @@ -0,0 +1,55 @@ +function Start-CIPPBecIPReviewJob { + <# + .SYNOPSIS + Queues a BEC IP review as a background scheduled task with live progress. + .DESCRIPTION + The background path of ExecBECIPReview, shaped like containment: creates the progress row so the + caller can poll at once, then queues Invoke-CIPPBecIPReview as a run-now scheduled task that fills + it with one step per stage. Returns the DeploymentId to poll with ListOffboardingProgress. + .PARAMETER TenantFilter + Tenant default domain name. + .PARAMETER CaseId + The BEC case to review. + .PARAMETER Overrides + The validated overrides ({ IP, Verdict, Note }). + .PARAMETER CorrelateUserIds + Object ids of accounts to correlate. + .PARAMETER UserPrincipalName + The investigated user (for the task name). + .PARAMETER Headers + The requesting user's headers. + .FUNCTIONALITY + Internal + #> + [CmdletBinding(SupportsShouldProcess = $true)] + param( + [Parameter(Mandatory = $true)][string]$TenantFilter, + [Parameter(Mandatory = $true)][string]$CaseId, + [object[]]$Overrides = @(), + [string[]]$CorrelateUserIds = @(), + [string]$UserPrincipalName, + $Headers + ) + + if (-not $PSCmdlet.ShouldProcess("$TenantFilter/$CaseId", 'Queue BEC IP review')) { return } + $DeploymentId = New-CIPPAsyncDeployment -Names @($CaseId) -Source 'BECIPReview' -TenantFilter $TenantFilter + $Task = [pscustomobject]@{ + TenantFilter = $TenantFilter + Name = "BEC IP review: $(if ($UserPrincipalName) { $UserPrincipalName } else { $CaseId })" + Command = @{ value = 'Invoke-CIPPBecIPReview' } + Parameters = [pscustomobject]@{ + CaseId = $CaseId + Overrides = @($Overrides) + CorrelateUserIds = @($CorrelateUserIds) + DeploymentId = $DeploymentId + APIName = 'BECIPReview' + } + } + # the scheduler reports a refused task as a returned 'Error - ...' string, not a throw + $Queued = Add-CIPPScheduledTask -Task $Task -hidden $false -RunNow -Headers $Headers + if ([string]$Queued -match '^(Error|Could not)') { + Set-CIPPAsyncDeploymentStatus -JobId $DeploymentId -Name $CaseId -Status 'failed' + throw [string]$Queued + } + return $DeploymentId +} diff --git a/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 index 5717ff6a15fe9..a55c830a72763 100644 --- a/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Export-CIPPBaselineTemplate.ps1 @@ -135,6 +135,7 @@ function Export-CIPPBaselineTemplate { excludedTenants = @() alertEmails = '' alertWebhookUrl = '' + disableAlerts = $false stages = @($ExportStages) referencedTemplates = @($Manifest) } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 index b488440cdc769..e62f16d04aaae 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaseline.ps1 @@ -32,6 +32,9 @@ function Get-CIPPBaseline { $RolloutRows = Get-CIPPAzDataTableEntity @RolloutTable -Filter $Filter if (-not $RolloutRows) { return } + $RepoTable = Get-CippTable -tablename 'CommunityRepos' + $Repos = @(Get-CIPPAzDataTableEntity @RepoTable -Filter "PartitionKey eq 'CommunityRepos'") + $DeltaTable = Get-CippTable -tablename 'Baselines' $StateTable = Get-CippTable -tablename 'BaselineRolloutState' @@ -113,6 +116,13 @@ function Get-CIPPBaseline { $StageDefinitions = @($RolloutRow.Stages | ConvertFrom-Json -ErrorAction Stop) $ExcludedTenants = @() try { if ($RolloutRow.excludedTenants) { $ExcludedTenants = @($RolloutRow.excludedTenants | ConvertFrom-Json) } } catch { } + # Expand a stored group Id to its member domains, same as assignment scopes. Raw + # values (including group Ids) stay in $ExcludedTenants for the exclusions display. + $ExpandedExcludedTenants = @($ExcludedTenants | ForEach-Object { + $Value = $_ + $Group = $Groups | Where-Object { $_.Id -eq $Value } | Select-Object -First 1 + if ($Group) { $Group.Members.defaultDomainName } else { $Value } + } | Select-Object -Unique) # The standards per stage come from the delta rows for this baseline. $SafeGuid = ConvertTo-CIPPODataFilterValue -Value $GUID @@ -234,7 +244,7 @@ function Get-CIPPBaseline { $Assignment.scopeId } } - $AssignedDomains = @($AssignedDomains | Where-Object { $_ -and $_ -ne 'Exported Template' -and $ExcludedTenants -notcontains $_ } | Select-Object -Unique) + $AssignedDomains = @($AssignedDomains | Where-Object { $_ -and $_ -ne 'Exported Template' -and $ExpandedExcludedTenants -notcontains $_ } | Select-Object -Unique) foreach ($Domain in $AssignedDomains) { if ($TenantStates.tenantFilter -notcontains $Domain) { $TenantStates.Add((& $NewState $Domain 1 $RolloutRow.updatedAt)) @@ -257,6 +267,7 @@ function Get-CIPPBaseline { } } + $IsRepoSource = Test-CIPPRepoSource -Source $RolloutRow.Source [PSCustomObject]@{ GUID = $GUID templateName = $RolloutRow.templateName @@ -265,10 +276,15 @@ function Get-CIPPBaseline { assignedTenants = $AssignedTenants assignments = $(if ($AssignedTo.Count -gt 0) { $AssignedTo } else { $Assignments }) exclusions = $(if ($ExcludedTo.Count -gt 0) { $ExcludedTo } else { @($ExcludedTenants | ForEach-Object { [PSCustomObject]@{ label = $_; value = $_ } }) }) - excludedTenants = $ExcludedTenants + excludedTenants = $ExpandedExcludedTenants alertEmails = $RolloutRow.alertEmails alertWebhookUrl = $RolloutRow.alertWebhookUrl + disableAlerts = [bool]$RolloutRow.disableAlerts disableScheduledRuns = [bool]$RolloutRow.disableScheduledRuns + source = $(if ($IsRepoSource) { $RolloutRow.Source } else { $null }) + isSynced = ($IsRepoSource -and ![string]::IsNullOrEmpty($RolloutRow.SHA)) + sourceUrl = $(if ($IsRepoSource) { Get-CIPPTemplateSourceUrl -Source $RolloutRow.Source -SourcePath $RolloutRow.SourcePath -Repos $Repos } else { $null }) + hasLocalChanges = $(if ($IsRepoSource) { [bool]$RolloutRow.LocalChanges } else { $null }) standardsCount = $UniqueStandards.Count stageNames = @($Stages.name) stages = $Stages diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 index 6e76a10c3ce29..e6c8acf48ff3a 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineAlignment.ps1 @@ -475,14 +475,14 @@ function Get-CIPPBaselineAlignment { } $Fleet = & $ScoreRows $Rows - # Trend = the daily rollups Set-CIPPBaselineTrendPoint writes after every run (last - # 90 days), with today's point always replaced by the LIVE score so the chart never - # lags the rest of the page. + # Trend = the daily rollups Set-CIPPBaselineTrendPoint writes after every run, capped + # at 14 days (13 stored days + today), with today's point always replaced by the LIVE + # score so the chart never lags the rest of the page. $Today = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd') $Trend = [System.Collections.Generic.List[object]]::new() try { $TrendTable = Get-CippTable -tablename 'BaselineTrend' - $Cutoff = (Get-Date).ToUniversalTime().AddDays(-90).ToString('yyyy-MM-dd') + $Cutoff = (Get-Date).ToUniversalTime().AddDays(-13).ToString('yyyy-MM-dd') $TrendRows = @(Get-CIPPAzDataTableEntity @TrendTable -Filter "PartitionKey eq 'fleet' and RowKey ge '$Cutoff' and RowKey lt '$Today'") | Sort-Object -Property RowKey foreach ($Point in $TrendRows) { $Trend.Add([PSCustomObject]@{ date = $Point.RowKey; aligned = [int]$Point.Aligned; verified = [int]$Point.Verified }) diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDevicePrepProfileState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDevicePrepProfileState.ps1 index 98bbd953c2f36..78897ef978969 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDevicePrepProfileState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineDevicePrepProfileState.ps1 @@ -7,7 +7,10 @@ function Get-CIPPBaselineDevicePrepProfileState { parses the enrollment_autopilot_dpp settings back into the classic's property set. The device security group resolves by name LIVE (the classic did the same lookup for its expected value); a group that does not exist yet grades as an empty id - the - executor creates it when CreateNewGroup allows. + executor creates it when CreateNewGroup allows. The group Intune actually enrols devices + into is read LIVE too, from the marker CIPP wrote when it applied one or from the + membership target action - never from the settings string the portal displays, which + every create writes whether or not the group was ever applied. The assignment grades separately through Compare-CIPPIntuneAssignments off the cached assignments; a failed or unknown lookup leaves the dimension out entirely, because a @@ -36,7 +39,7 @@ function Get-CIPPBaselineDevicePrepProfileState { $DeploymentType = [string]($V.DeploymentType.value ?? $V.DeploymentType ?? '0') $JoinType = [string]($V.JoinType.value ?? $V.JoinType ?? '0') - $AccountType = [string]($V.AccountType.value ?? $V.AccountType ?? '0') + $AccountType = [string]($V.AccountType.value ?? $V.AccountType ?? '1') # 1 = Standard user, 0 = Administrator # Empty string means unset, exactly like a pruned remediate key: '' ?? falls through # and [int]'' is 0, which graded timeout 0 against the 60 the executor writes. $Timeout = if ([string]::IsNullOrWhiteSpace("$($V.Timeout)")) { 60 } else { [int]"$($V.Timeout)" } @@ -70,7 +73,6 @@ function Get-CIPPBaselineDevicePrepProfileState { $SimpleSettingMap = @{ 'enrollment_autopilot_dpp_timeout' = 'timeout' 'enrollment_autopilot_dpp_customerrormessage' = 'customErrorMessage' - 'enrollment_autopilot_dpp_devicesecuritygroupids' = 'deviceGroupId' } $Parsed = @{} foreach ($Setting in @($Policy.settings)) { @@ -93,7 +95,6 @@ function Get-CIPPBaselineDevicePrepProfileState { customErrorMessage = $CustomErrorMessage allowSkip = $AllowSkip allowDiagnostics = $AllowDiagnostics - deviceGroupId = $DeviceGroupId } $Current = [PSCustomObject]@{ profileExists = ($null -ne $Policy) @@ -105,7 +106,17 @@ function Get-CIPPBaselineDevicePrepProfileState { customErrorMessage = [string]($Parsed.customErrorMessage ?? '') allowSkip = [string]($Parsed.allowSkip ?? '') allowDiagnostics = [string]($Parsed.allowDiagnostics ?? '') - deviceGroupId = [string]($Parsed.deviceGroupId ?? '') + } + + # The applied group comes from the marker CIPP wrote, or from the membership target action + # where it routes. The cached settings string is not consulted: every create writes it while + # the group itself stays unapplied, so it would report a half-deployed profile as healthy. + # An expected group that resolved to nothing is a deviation nothing here could clear - the + # prepare hook never writes - so the dimension stays out until it exists. + if ($null -ne $Policy -and -not [string]::IsNullOrWhiteSpace($DeviceGroupId)) { + $MembershipTarget = Get-CIPPEnrollmentTimeDeviceMembershipTarget -PolicyId "$($Policy.id)" -TenantFilter $TenantFilter + $Expected | Add-Member -NotePropertyName 'deviceGroupId' -NotePropertyValue $DeviceGroupId + $Current | Add-Member -NotePropertyName 'deviceGroupId' -NotePropertyValue ([string]$MembershipTarget.GroupId) } # Assignment dimension: graded only when requested AND readable. @@ -125,7 +136,9 @@ function Get-CIPPBaselineDevicePrepProfileState { # Carried for the executor: repair-in-place needs to know the settings verdict alone. $SettingsCorrect = $null -ne $Policy if ($SettingsCorrect) { - foreach ($Prop in @('deploymentMode', 'deploymentType', 'joinType', 'accountType', 'allowSkip', 'allowDiagnostics', 'customErrorMessage', 'deviceGroupId')) { + # deviceGroupId stays out: the executor applies it to the existing policy, so grading it + # here would send a present-but-unassigned group down the delete-and-recreate path. + foreach ($Prop in @('deploymentMode', 'deploymentType', 'joinType', 'accountType', 'allowSkip', 'allowDiagnostics', 'customErrorMessage')) { if ("$($Current.$Prop)" -ne "$($Expected.$Prop)") { $SettingsCorrect = $false; break } } if ($SettingsCorrect -and [int]$Current.timeout -ne [int]$Expected.timeout) { $SettingsCorrect = $false } diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 index cbadb4b959646..9f096e7e32a76 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineEnableAppConsentRequestsState.ps1 @@ -35,8 +35,12 @@ function Get-CIPPBaselineEnableAppConsentRequestsState { $Roles = @(@($Item.Variables.ReviewerRoles) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($Roles.Count -eq 0) { $Roles = @('62e90394-69f5-4237-9190-012177145e10') } + # Missing roles are reported by name (the classic standard does the same), falling back to the id + $RoleLabels = @{ '62e90394-69f5-4237-9190-012177145e10' = 'Global Administrator' } + foreach ($Role in @($Item.Variables.ReviewerRoles)) { if ($Role.value) { $RoleLabels["$($Role.value)"] = $Role.label ?? $Role.value } } + $ReviewerQueries = @(@($Policy.reviewers) | ForEach-Object { "$($_.query)" }) - $MissingRoles = @($Roles | Where-Object { $Role = $_; -not ($ReviewerQueries | Where-Object { $_ -match $Role }) }) + $MissingRoles = @($Roles | Where-Object { $Role = $_; -not ($ReviewerQueries | Where-Object { $_ -match [regex]::Escape($Role) }) } | ForEach-Object { $RoleLabels[$_] ?? $_ }) $UserNames = @(@($Item.Variables.ReviewerUsers) | ForEach-Object { "$($_.value ?? $_)" } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) $MissingUsers = @() diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUsageLocationState.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUsageLocationState.ps1 new file mode 100644 index 0000000000000..e5cf445b160f8 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineUsageLocationState.ps1 @@ -0,0 +1,114 @@ +function Get-CIPPBaselineUsageLocationState { + <# + .SYNOPSIS + Prepare hook for UsageLocation: member accounts whose usage location is not the + configured country. + .DESCRIPTION + Members only - guests are never licensed through the tenant and their location is + their own organisation's business. Directory-synced accounts ARE included: usage + location is cloud-managed and the admin centre lets you set it on synced users, so + skipping them would leave exactly the accounts a hybrid tenant most needs fixed. + + Group scoping works on display names because a baseline applies to many tenants and + a group id only exists in one. Names resolve against the Groups cache (collected on a + miss) and expand LIVE to transitive user members - the cache holds direct members + only and nothing for dynamic groups, and a nested group's users are still members. + A name that resolves to no group, or a membership lookup that fails, returns a null + Current (No Data): sweeping with a half-resolved exclusion list is how an overseas + office ends up with the wrong country and broken licences. + + 'onlyWhenBlank' turns the compare from 'equals the configured country' into 'has any + value at all', for tenants where locations were set by hand per user. + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param( + $Item, + $TenantFilter + ) + + $Users = @(New-CIPPDbRequest -TenantFilter $TenantFilter -Type 'Users' | Where-Object { $_ }) + if ($Users.Count -eq 0) { return @{ Current = $null } } + + $Wanted = "$($Item.Variables.usageLocation.value ?? $Item.Variables.usageLocation)".Trim().ToUpperInvariant() + if ($Wanted -notmatch '^[A-Z]{2}$') { + Write-Information "Baselines: UsageLocation on $TenantFilter has no valid two-letter country code configured ('$Wanted')." + return @{ Current = $null } + } + $OnlyWhenBlank = [bool]($Item.Variables.onlyWhenBlank -eq $true) + + # Picker values arrive flattened by the engine, but a one-off or a test may still hand + # over the {label, value} wrappers the form saves. + $ToNames = { + param($Value) + @(@($Value) | ForEach-Object { + if ($_ -is [string]) { $_ } else { "$($_.value ?? $_.label)" } + } | ForEach-Object { $_.Trim() } | Where-Object { $_ } | Select-Object -Unique) + } + $IncludeNames = & $ToNames $Item.Variables.includeGroups + $ExcludeNames = & $ToNames $Item.Variables.excludeGroups + + $IncludedIds = @{} + $ExcludedIds = @{} + if ($IncludeNames.Count -gt 0 -or $ExcludeNames.Count -gt 0) { + $Groups = @(Get-CIPPBaselineCacheRows -TenantFilter $TenantFilter -Type 'Groups') + $GroupIdsByName = @{} + foreach ($Group in $Groups) { + $Name = "$($Group.displayName)".Trim() + if (-not $Name -or -not $Group.id) { continue } + if (-not $GroupIdsByName.ContainsKey($Name)) { $GroupIdsByName[$Name] = [System.Collections.Generic.List[string]]::new() } + $GroupIdsByName[$Name].Add("$($Group.id)") + } + # Case-insensitive lookup: the hashtable literal above is case-insensitive already, + # this just makes the intent explicit for the reader. + $Unresolved = @(@($IncludeNames) + @($ExcludeNames) | Where-Object { -not $GroupIdsByName.ContainsKey($_) }) + if ($Unresolved.Count -gt 0) { + Write-Information "Baselines: UsageLocation on $TenantFilter cannot resolve group(s) $($Unresolved -join ', ') - refusing to sweep with a partial scope." + return @{ Current = $null } + } + + $Requests = [System.Collections.Generic.List[hashtable]]::new() + foreach ($Name in $IncludeNames) { + foreach ($GroupId in $GroupIdsByName[$Name]) { + $Requests.Add(@{ id = "include-$GroupId"; method = 'GET'; url = "groups/$GroupId/transitiveMembers/microsoft.graph.user?`$select=id&`$top=999" }) + } + } + foreach ($Name in $ExcludeNames) { + foreach ($GroupId in $GroupIdsByName[$Name]) { + $Requests.Add(@{ id = "exclude-$GroupId"; method = 'GET'; url = "groups/$GroupId/transitiveMembers/microsoft.graph.user?`$select=id&`$top=999" }) + } + } + + try { + $Responses = @(New-GraphBulkRequest -tenantid $TenantFilter -Requests @($Requests) -asapp $true -Version 'v1.0') + } catch { + Write-Information "Baselines: UsageLocation group membership lookup on $TenantFilter failed, refusing to sweep: $($_.Exception.Message)" + return @{ Current = $null } + } + foreach ($Response in $Responses) { + if ([int]$Response.status -lt 200 -or [int]$Response.status -gt 299) { + Write-Information "Baselines: UsageLocation group membership lookup on $TenantFilter returned $($Response.status) for $($Response.id), refusing to sweep." + return @{ Current = $null } + } + $Bucket = if ("$($Response.id)".StartsWith('include-')) { $IncludedIds } else { $ExcludedIds } + foreach ($MemberId in @($Response.body.value.id | Where-Object { $_ })) { $Bucket["$MemberId"] = $true } + } + } + + $Candidates = @($Users | Where-Object { $_.userType -eq 'Member' -and $_.id }) + if ($IncludeNames.Count -gt 0) { $Candidates = @($Candidates | Where-Object { $IncludedIds.ContainsKey("$($_.id)") }) } + if ($ExcludeNames.Count -gt 0) { $Candidates = @($Candidates | Where-Object { -not $ExcludedIds.ContainsKey("$($_.id)") }) } + + $Incorrect = @($Candidates | Where-Object { + $Location = "$($_.usageLocation)".Trim().ToUpperInvariant() + if ($OnlyWhenBlank) { [string]::IsNullOrWhiteSpace($Location) } else { $Location -ne $Wanted } + }) + + @{ + Current = [PSCustomObject]@{ + offenders = @($Incorrect.userPrincipalName | Sort-Object) + targets = @($Incorrect | ForEach-Object { [PSCustomObject]@{ id = "$($_.id)" } }) + } + } +} diff --git a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 index 9d96137d4aec3..fb2e3a21b85c8 100644 --- a/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Get-CIPPBaselineWorkItems.ps1 @@ -150,8 +150,10 @@ function Get-CIPPBaselineWorkItems { TemplateName = $Baseline.templateName Variables = ($Config.variables ?? [PSCustomObject]@{}) RemediateEnabled = [bool]$Config.remediateEnabled - AlertEnabled = [bool]$Config.alertEnabled - AlertOnRemediate = [bool]$Config.alertOnRemediate + # 'Disable Alerts' on the baseline silences every standard in it, + # regardless of the per-standard alert switches. + AlertEnabled = [bool]$Config.alertEnabled -and -not [bool]$Baseline.disableAlerts + AlertOnRemediate = [bool]$Config.alertOnRemediate -and -not [bool]$Baseline.disableAlerts SourceScope = $Scope # Package members attribute their origin so the alignment view # reads 'Baseline X (PackageName)' instead of hiding the bundle. diff --git a/Modules/CIPPCore/Public/Baselines/Import-CIPPBaselineTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Import-CIPPBaselineTemplate.ps1 index 523c61dcb9ad9..cfaaadc1cbcd3 100644 --- a/Modules/CIPPCore/Public/Baselines/Import-CIPPBaselineTemplate.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Import-CIPPBaselineTemplate.ps1 @@ -26,6 +26,7 @@ function Import-CIPPBaselineTemplate { $FullName, $Branch, $SHA, + $Path, $User, [switch]$Force ) @@ -49,8 +50,9 @@ function Import-CIPPBaselineTemplate { if (-not $Reference) { continue } try { $File = $null + $FilePath = $null if ("$($Reference.path)") { - try { $File = Get-GitHubFileContents -FullName $FullName -Path $Reference.path -Branch $Branch } catch { $File = $null } + try { $File = Get-GitHubFileContents -FullName $FullName -Path $Reference.path -Branch $Branch; $FilePath = $Reference.path } catch { $File = $null } } if (-not $File) { # The file moved or was renamed: find it by sanitized display name under @@ -61,14 +63,14 @@ function Import-CIPPBaselineTemplate { $_.path -match '\.json$' -and $_.path -like "$($Reference.partition)/*" -and (((($_.path -split '/')[-1]) -replace '\.json$', '') -eq $Sanitized) } | Select-Object -First 1 - if ($Candidate) { $File = Get-GitHubFileContents -FullName $FullName -Path $Candidate.path -Branch $Branch } + if ($Candidate) { $File = Get-GitHubFileContents -FullName $FullName -Path $Candidate.path -Branch $Branch; $FilePath = $Candidate.path } } if (-not $File) { $Failures.Add("$($Reference.displayName) ($($Reference.partition)) was not found in the repository") continue } $Content = $File.content | ConvertFrom-Json -Depth 100 - $null = Import-CommunityTemplate -Template $Content -SHA $File.sha -Source $FullName -Force:$Force + $null = Import-CommunityTemplate -Template $Content -SHA $File.sha -Source $FullName -Path $FilePath -Force:$Force $ImportedCount++ } catch { $Failures.Add("$($Reference.displayName): $($_.Exception.Message)") @@ -83,9 +85,11 @@ function Import-CIPPBaselineTemplate { excludedTenants = @() alertEmails = '' alertWebhookUrl = '' + disableAlerts = $false stages = @($Baseline.stages) } - $Saved = New-CIPPBaseline -Baseline $Payload -User ("$User" ? "$User" : 'GitHub Import') -Source "$FullName" -SHA "$SHA" + # An import lands the repo copy exactly as-is, so there is nothing local yet to push back. + $Saved = New-CIPPBaseline -Baseline $Payload -User ("$User" ? "$User" : 'GitHub Import') -Source "$FullName" -SHA "$SHA" -SourcePath "$Path" -LocalChanges:$false $Message = "Imported baseline '$($Baseline.templateName)' ($($Saved.DeltaCount) delta rows) with $ImportedCount related template$(if ($ImportedCount -eq 1) { '' } else { 's' }). Assign it to real tenants in the editor - it arrives assigned to the 'Exported Template' placeholder." if ($Failures.Count -gt 0) { diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDevicePrepProfile.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDevicePrepProfile.ps1 index fef9cdf59181f..fb243677aedd2 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDevicePrepProfile.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineDevicePrepProfile.ps1 @@ -6,9 +6,11 @@ function Invoke-CIPPBaselineDevicePrepProfile { The classic's three-way write. Settings correct but assignment wrong: repair the assignment IN PLACE through /assign - recreating would sever the enrollment-time device group linkage over a delta the endpoint can fix. Otherwise: delete the drifted - profile and recreate it with the classic's exact settings body, resolving (or, when - CreateNewGroup allows, creating with the Intune Provisioning Client as owner) the - device security group first. + profile and recreate it with the classic's exact settings body. The device security group + is resolved (or, when CreateNewGroup allows, created with the Intune Provisioning Client as + owner) before either branch, because the group Intune enrols devices into is applied + through setEnrollmentTimeDeviceMembershipTarget on the policy rather than by the settings + string, so both branches have to write it. .FUNCTIONALITY Internal #> @@ -25,7 +27,7 @@ function Invoke-CIPPBaselineDevicePrepProfile { $DeploymentMode = '0' # Device Prep only supports self-deploying mode $DeploymentType = [string]($Remediate.deploymentType.value ?? $Remediate.deploymentType ?? '0') $JoinType = [string]($Remediate.joinType.value ?? $Remediate.joinType ?? '0') - $AccountType = [string]($Remediate.accountType.value ?? $Remediate.accountType ?? '0') + $AccountType = [string]($Remediate.accountType.value ?? $Remediate.accountType ?? '1') # 1 = Standard user, 0 = Administrator $Timeout = if ([string]::IsNullOrWhiteSpace("$($Remediate.timeout)")) { 60 } else { [int]"$($Remediate.timeout)" } $CustomErrorMessage = if ([string]::IsNullOrWhiteSpace("$($Remediate.customErrorMessage)")) { "Contact your organization$([char]0x2019)s support person for help." } else { "$($Remediate.customErrorMessage)" } $AllowSkip = $(if ($Remediate.allowSkip -eq $true) { '1' } else { '0' }) @@ -40,15 +42,6 @@ function Invoke-CIPPBaselineDevicePrepProfile { Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "DevicePrepProfile: $($AssignmentTarget.Unsupported)" -Sev 'Warning' } - # Settings already correct means only the assignment drifted: repair it in place. - if ($Current.settingsCorrect -eq $true -and -not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { - if ($AssignmentBody) { - $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')/assign" -tenantid $TenantFilter -body $AssignmentBody -type POST - Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Repaired the assignment for Device Prep profile '$ProfileName'." -Sev 'Info' - } - return - } - # Resolve or create the device security group, the classic's flow. $DeviceGroupId = '' if (-not [string]::IsNullOrWhiteSpace("$($Remediate.deviceGroupName)")) { @@ -84,8 +77,26 @@ function Invoke-CIPPBaselineDevicePrepProfile { } } + # Settings already correct means only the assignment or the membership target drifted: + # repair them in place. + if ($Current.settingsCorrect -eq $true -and -not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { + # isAssigned is absent when the assignment could not be graded, and a blind re-post is + # the safe answer there; a graded-correct assignment is left alone. + if ($AssignmentBody -and $Current.isAssigned -ne $true) { + $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')/assign" -tenantid $TenantFilter -body $AssignmentBody -type POST + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Repaired the assignment for Device Prep profile '$ProfileName'." -Sev 'Info' + } + if (-not [string]::IsNullOrWhiteSpace($DeviceGroupId) -and "$($Current.deviceGroupId)" -ne $DeviceGroupId) { + $null = Set-CIPPEnrollmentTimeDeviceMembershipTarget -PolicyId "$($Current.policyId)" -GroupId $DeviceGroupId -TenantFilter $TenantFilter + Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Applied the enrollment time device membership target for Device Prep profile '$ProfileName'." -Sev 'Info' + } + return + } + if (-not [string]::IsNullOrWhiteSpace("$($Current.policyId)")) { $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($Current.policyId)')" -tenantid $TenantFilter -type DELETE + # The recreated profile gets a new id, so the old marker must not survive it. + Remove-CIPPEnrollmentTimeDeviceMembershipMarker -PolicyId "$($Current.policyId)" -TenantFilter $TenantFilter Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deleted the drifted Device Prep profile '$ProfileName' for recreation." -Sev 'Info' } @@ -163,5 +174,8 @@ function Invoke-CIPPBaselineDevicePrepProfile { if ("$($NewPolicy.id)" -and $AssignmentBody) { $null = New-GraphPostRequest -uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($NewPolicy.id)')/assign" -tenantid $TenantFilter -body $AssignmentBody -type POST } + if ("$($NewPolicy.id)" -and -not [string]::IsNullOrWhiteSpace($DeviceGroupId)) { + $null = Set-CIPPEnrollmentTimeDeviceMembershipTarget -PolicyId "$($NewPolicy.id)" -GroupId $DeviceGroupId -TenantFilter $TenantFilter + } Write-LogMessage -API 'Baselines' -tenant $TenantFilter -message "Deployed the Device Prep profile '$ProfileName'." -Sev 'Info' } diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 index e0440757d662d..b0fb829efbdb7 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineExoRequest.ps1 @@ -28,6 +28,35 @@ function Invoke-CIPPBaselineExoRequest { foreach ($Property in ($Step.params ?? [PSCustomObject]@{}).PSObject.Properties) { $CmdParams[$Property.Name] = $Property.Value } + + # A GenericHashTable edit (e.g. AllowList Add/Remove) with nothing but empty + # values still shapes as a real edit and Exchange rejects it with "MultiValuedProperty + # collections cannot contain null values". Strip empties from Add/Remove, then drop + # the whole param when nothing real is left. + foreach ($Key in @($CmdParams.Keys)) { + $Value = $CmdParams[$Key] + # Only an object/dictionary can carry an @odata.type edit - null, strings, + # numbers and bools indexed the same way threw "Cannot index into a null array." + if ($null -eq $Value -or $Value -is [string] -or $Value -is [ValueType]) { continue } + $IsHash = $Value -is [System.Collections.IDictionary] + $ODataType = if ($IsHash) { $Value['@odata.type'] } else { $Value.PSObject.Properties['@odata.type'].Value } + if ("$ODataType" -ne '#Exchange.GenericHashTable') { continue } + + $AnyReal = $false + foreach ($EditKey in @('Add', 'Remove')) { + $HasEditKey = if ($IsHash) { $Value.Contains($EditKey) } else { $null -ne $Value.PSObject.Properties[$EditKey] } + if (-not $HasEditKey) { continue } + $EditValue = if ($IsHash) { $Value[$EditKey] } else { $Value.PSObject.Properties[$EditKey].Value } + $Cleaned = @($EditValue | Where-Object { $null -ne $_ -and -not ($_ -is [string] -and [string]::IsNullOrWhiteSpace($_)) }) + if ($Cleaned.Count -gt 0) { $AnyReal = $true } + if ($IsHash) { $Value[$EditKey] = $Cleaned } else { $Value.$EditKey = $Cleaned } + } + if (-not $AnyReal) { $CmdParams.Remove($Key) } + } + # .Count would collide with a cmdlet param literally named "Count" (the hashtable + # returns that key's VALUE, not the entry count) - .Keys.Count is unambiguous. + if ($CmdParams.Keys.Count -eq 0 -and @(($Step.params ?? [PSCustomObject]@{}).PSObject.Properties).Count -gt 0) { continue } + try { $null = New-ExoRequest -tenantid $TenantFilter -cmdlet $Step.cmdlet -cmdParams $CmdParams -useSystemMailbox $true -Compliance:([bool]($Step.compliance ?? $false)) } catch { diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 index dda0b8a06e45c..2ca327573366b 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineIntuneTemplate.ps1 @@ -36,12 +36,20 @@ function Invoke-CIPPBaselineIntuneTemplate { if ($Synced.RawJSON) { $RawJson = $Synced.RawJSON } $RawJson = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $RawJson -EscapeForJson + # Resolve %variables% in the identity columns the same way. Column-named types deploy under this + # name and are looked up by it, so a raw token creates a duplicate on every deploy. Plain text, + # not JSON-escaped, since these fill bare string slots. Names without a variable are untouched. + $DisplayName = "$($Template.Displayname)" + if ($DisplayName -match '%') { $DisplayName = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $DisplayName } + $Description = "$($Template.Description)" + if ($Description -match '%') { $Description = Get-CIPPTextReplacement -TenantFilter $TenantFilter -Text $Description } + # customGroup overrides AssignTo, matching the old engine's behavior. $AssignTo = if ("$($Remediate.customGroup)") { "$($Remediate.customGroup)" } else { "$($Remediate.assignTo)" } $PolicyParams = @{ TemplateType = $TemplateType - Description = "$($Template.Description)" - DisplayName = "$($Template.Displayname)" + Description = $Description + DisplayName = $DisplayName RawJSON = $RawJson AssignTo = $AssignTo ExcludeGroup = "$($Remediate.excludeGroup)" diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMigration.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMigration.ps1 index 475937dcc5978..27fca4064c0f9 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMigration.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineMigration.ps1 @@ -57,6 +57,8 @@ function Invoke-CIPPBaselineMigration { AutopilotStatusPage = @{ drop = @('AllowRetry'); dropNotes = @{ AllowRetry = "a leftover from an old classic-standard version - the current classic standard ignores it too (the 'Block device usage during setup' switch drives the retry setting)" } } Bookings = @{ rename = @{ state = 'enabled' } } CloudMessageRecall = @{ rename = @{ state = 'enabled' } } + # V2 stored the SPO enum name; the V3 definition speaks SPO numerics. + DefaultSharingLink = @{ value = @{ sharingLinkType = @{ Direct = 1; Internal = 2 } } } ConditionalAccessTemplate = @{ rename = @{ TemplateList = 'caTemplate' }; value = @{ state = @{ Enabled = 'enabled'; Disabled = 'disabled' } } } DisableAddShortcutsToOneDrive = @{ rename = @{ state = 'disableAddToOneDrive' } } EnableMailTips = @{ rename = @{ MailTipsLargeAudienceThreshold = 'largeAudienceThreshold' } } @@ -68,6 +70,8 @@ function Invoke-CIPPBaselineMigration { sharingCapability = @{ rename = @{ Level = 'sharingCapability' }; value = @{ sharingCapability = @{ disabled = 0; externalUserSharingOnly = 1; externalUserAndGuestSharing = 2; existingExternalUserSharingOnly = 3 } } } SpoofWarn = @{ rename = @{ state = 'externalWarningEnabled' }; value = @{ externalWarningEnabled = @{ enabled = $true; disabled = $false } } } SPSyncButtonState = @{ rename = @{ state = 'hideSyncButton' } } + # V2 were switches (bool); V3 passes the Teams cmdlet's 'Enabled'/'Disabled' strings. + TeamsChatProtection = @{ value = @{ FileTypeCheck = @{ True = 'Enabled'; False = 'Disabled' }; UrlReputationCheck = @{ True = 'Enabled'; False = 'Disabled' } } } TAP = @{ rename = @{ config = 'isUsableOnce' } } unmanagedSync = @{ rename = @{ state = 'conditionalAccessPolicy' } } BitLockerKeysForOwnedDevice = @{ rename = @{ state = 'allowed' }; value = @{ allowed = @{ allow = $true; restrict = $false } } } @@ -365,7 +369,7 @@ function Invoke-CIPPBaselineMigration { $SourceMarker = "StandardsTemplateV2:$V2Guid" # mapper= is the migration logic version: bump it when the MAPPING changes (not the # source data) so unchanged V2 templates still re-commit once with the improved output. - $Sha = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes("$($Row.JSON)|reportOnly=$ReportOnly|detect=$AddDetectStandards|mapper=2"))).ToLower() + $Sha = [System.Convert]::ToHexString([System.Security.Cryptography.SHA256]::HashData([System.Text.Encoding]::UTF8.GetBytes("$($Row.JSON)|reportOnly=$ReportOnly|detect=$AddDetectStandards|mapper=3"))).ToLower() $SafeSource = ConvertTo-CIPPODataFilterValue -Value $SourceMarker $Existing = Get-CIPPAzDataTableEntity @RolloutTable -Filter "PartitionKey eq 'rollout' and Source eq '$SafeSource'" | Select-Object -First 1 if ($Existing -and "$($Existing.SHA)" -eq $Sha) { diff --git a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 index 800f3419e9214..9e028befe9555 100644 --- a/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Invoke-CIPPBaselineStandard.ps1 @@ -260,7 +260,7 @@ function Invoke-CIPPBaselineStandard { # Per-property verdicts default to 'accept' (tolerate); 'denyDelete' marks the path's # object for deletion. Both filter the diff; deny-delete parks the row at Delete # Pending instead of scoring it Accepted. - $AcceptedPaths = $(try { $Prior.AcceptedPaths | ConvertFrom-Json } catch { $null }) + $AcceptedPaths = $(try { if ($Prior.AcceptedPaths) { $Prior.AcceptedPaths | ConvertFrom-Json -ErrorAction Stop } } catch { $null }) $AcceptedKeys = @($AcceptedPaths.PSObject.Properties.Name | Where-Object { $_ }) $DenyDeleteKeys = @($AcceptedPaths.PSObject.Properties | Where-Object { $_.Name -and $_.Value.verdict -eq 'denyDelete' } | ForEach-Object { $_.Name }) $ExpectedTemplate = & $Render $Definition.expected $Item.Variables @@ -360,7 +360,7 @@ function Invoke-CIPPBaselineStandard { if ($GradeOnly) { return $null } $Manual = & $Render $Definition.manual $Item.Variables $Result.Manual = $Manual - $Completed = [bool]($(try { $Prior.CurrentValue | ConvertFrom-Json } catch { $null })?.completed) + $Completed = [bool]($(try { if ($Prior.CurrentValue) { $Prior.CurrentValue | ConvertFrom-Json -ErrorAction Stop } } catch { $null })?.completed) $LastDone = if ("$($Prior.LastRemediated)" -match '^\d+$') { [int64]$Prior.LastRemediated } else { 0 } $ReopenSeconds = switch ($Manual.reopen) { 'weekly' { 7 * 86400 } diff --git a/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 b/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 index 7e07b5aeae903..774fefbf93815 100644 --- a/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 +++ b/Modules/CIPPCore/Public/Baselines/New-CIPPBaseline.ps1 @@ -24,7 +24,9 @@ function New-CIPPBaseline { $Baseline, $User, $Source, - $SHA + $SHA, + $SourcePath, + [bool]$LocalChanges ) if (-not $Baseline.templateName) { @@ -49,13 +51,16 @@ function New-CIPPBaseline { $SafeGuid = ConvertTo-CIPPODataFilterValue -Value $GUID $ExistingRollout = Get-CIPPAzDataTableEntity @RolloutTable -Filter "PartitionKey eq 'rollout' and RowKey eq '$SafeGuid'" | Select-Object -First 1 $RolloutTable.Force = $true + # Explicit -LocalChanges wins; otherwise carry the existing rollout row's flag across, + # same as Source/SHA/SourcePath, so an editor re-save doesn't silently clear it. + $LocalChangesValue = if ($PSBoundParameters.ContainsKey('LocalChanges')) { $LocalChanges } else { $ExistingRollout.LocalChanges } # The editor round-trips the tenant selector's own option objects ({label, value, # type}) verbatim via assignedTo/excludedTo; the flat excludedTenants values keep # the exclusion logic simple. Raw string values are accepted everywhere too. $ExcludedValues = @($Baseline.excludedTenants | ForEach-Object { if ($null -eq $_) { } elseif ($_ -is [string]) { $_ } else { "$($_.value)" } } | Where-Object { $_ }) - Add-CIPPAzDataTableEntity @RolloutTable -Entity @{ + $RolloutEntity = @{ PartitionKey = 'rollout' RowKey = "$GUID" templateName = "$($Baseline.templateName)" @@ -65,6 +70,10 @@ function New-CIPPBaseline { excludedTenants = (ConvertTo-Json -Compress -Depth 10 -InputObject $ExcludedValues) alertEmails = "$($Baseline.alertEmails)" alertWebhookUrl = "$($Baseline.alertWebhookUrl)" + # 'Disable Alerts': deviations are still detected and shown, but no email, webhook + # or PSA notification fires for this baseline. Negative flag on purpose: rows saved + # before the column existed keep alerting, exactly right. + disableAlerts = [bool]$Baseline.disableAlerts # 'Disable Scheduled Runs': the baseline only executes when an operator runs it. # Negative flag on purpose: rows saved before the column existed default to # scheduled, exactly right. @@ -74,7 +83,13 @@ function New-CIPPBaseline { updatedAt = $Now Source = "$($Source ?? $ExistingRollout.Source)" SHA = "$($SHA ?? $ExistingRollout.SHA)" + SourcePath = "$($SourcePath ?? $ExistingRollout.SourcePath)" } + # A brand-new baseline with no Source and no explicit flag gets no LocalChanges column at all. + if ($null -ne $LocalChangesValue) { + $RolloutEntity.LocalChanges = [bool]$LocalChangesValue + } + Add-CIPPAzDataTableEntity @RolloutTable -Entity $RolloutEntity # Explode into delta rows: RK --s-. # The stage is part of the key so the same standard can exist in two stages (the diff --git a/Modules/CIPPCore/Public/Baselines/Send-CIPPBaselineAlert.ps1 b/Modules/CIPPCore/Public/Baselines/Send-CIPPBaselineAlert.ps1 index 37420933171fd..98b32195f591c 100644 --- a/Modules/CIPPCore/Public/Baselines/Send-CIPPBaselineAlert.ps1 +++ b/Modules/CIPPCore/Public/Baselines/Send-CIPPBaselineAlert.ps1 @@ -1,55 +1,80 @@ function Send-CIPPBaselineAlert { <# .SYNOPSIS - Delivers a baseline deviation/remediation alert. + Queues a baseline deviation/remediation alert for the end-of-run digest. .DESCRIPTION - Fires on the transition into Drift (alertEnabled) and on auto-remediation - (alertOnRemediate). When the baseline configures its own alertEmails/alertWebhookUrl - those destinations OVERRIDE the instance-wide channels; otherwise the alert goes through - the standard CippNotifications config (email + webhook + PSA each no-op when unset). + Fires on the transition into Drift (alertEnabled), on auto-remediation + (alertOnRemediate) .FUNCTIONALITY Internal #> [CmdletBinding()] param($Result) - $Item = $Result.Item - $AlertEvent = $Result.AlertEvent - $Title = if ($AlertEvent -eq 'Remediated') { - "Baseline standard auto-remediated: $($Item.Standard) on $($Item.TenantFilter)" - } elseif ($AlertEvent -eq 'Conflict') { - "Baseline conflict: $($Item.Standard) on $($Item.TenantFilter) is configured differently by $(@($Item.ConflictWith) -join ' and ')" - } else { - "Baseline drift detected: $($Item.Standard) on $($Item.TenantFilter)" - } - $DiffLines = @($Result.Diff | ForEach-Object { - "
  • $($_.Property): expected '$($_.ExpectedValue)', found '$($_.ReceivedValue)'
  • " - }) -join '' - $HTMLContent = "

    $Title (baseline: $($Item.SourceTemplate), stage $($Item.Stage)).

    " + $(if ($DiffLines) { "
      $DiffLines
    " } else { '' }) - $JSONContent = ConvertTo-Json -Compress -Depth 100 -InputObject ([PSCustomObject]@{ - Event = $AlertEvent - Tenant = $Item.TenantFilter - Standard = $Item.Standard - Baseline = $Item.SourceTemplate - Stage = $Item.Stage - Differences = @($Result.Diff) - }) - try { - if ($Item.AlertEmails -or $Item.AlertWebhookUrl) { - # Baseline-level destinations override the instance-wide channels. - if ($Item.AlertEmails) { - Send-CIPPAlert -Type 'email' -Title $Title -HTMLContent $HTMLContent -TenantFilter $Item.TenantFilter -altEmail $Item.AlertEmails -APIName 'Baselines' - } - if ($Item.AlertWebhookUrl) { - Send-CIPPAlert -Type 'webhook' -Title $Title -JSONContent $JSONContent -TenantFilter $Item.TenantFilter -altWebhook $Item.AlertWebhookUrl -APIName 'Baselines' + $Item = $Result.Item + $Definition = $null + try { $Definition = Get-CIPPBaselineDefinition -Name $Item.BaseName } catch {} + $Label = $Definition.label ?? $Item.BaseName ?? $Item.Standard + $PolicyName = $Result.ExpectedValue.displayName + if ($PolicyName -is [string] -and -not [string]::IsNullOrWhiteSpace($PolicyName) -and $PolicyName -notmatch '\.json$') { + $Label = $PolicyName + } + $Description = "$($Definition.executiveText ?? $Definition.helpText)" + if ($Description.Length -gt 280) { $Description = "$($Description.Substring(0, 277))..." } + + $SettingLabels = @{} + $SettingOptions = @{} + foreach ($ExpectedProperty in (($Definition.expected ?? [PSCustomObject]@{}).PSObject.Properties)) { + if ($ExpectedProperty.Value -is [string] -and $ExpectedProperty.Value -match '^%(\w+)%$') { + $Variable = $Definition.variables.($Matches[1]) + if ($Variable.label -and $Variable.label -notmatch '\?\s*$') { $SettingLabels[$ExpectedProperty.Name] = "$($Variable.label)" } + if ($Variable.options) { $SettingOptions[$ExpectedProperty.Name] = @($Variable.options) } } - } else { - Send-CIPPAlert -Type 'email' -Title $Title -HTMLContent $HTMLContent -TenantFilter $Item.TenantFilter -APIName 'Baselines' - Send-CIPPAlert -Type 'webhook' -Title $Title -JSONContent $JSONContent -TenantFilter $Item.TenantFilter -APIName 'Baselines' - Send-CIPPAlert -Type 'psa' -Title $Title -HTMLContent $HTMLContent -TenantFilter $Item.TenantFilter -APIName 'Baselines' + } + $FriendlySetting = { + param($PropertyName) + if ($SettingLabels.ContainsKey("$PropertyName")) { return $SettingLabels["$PropertyName"] } + $Leaf = @("$PropertyName" -split '\.')[-1] + $Spaced = $Leaf -creplace '([a-z0-9])([A-Z])', '$1 $2' + if ($Spaced.Length -gt 0) { $Spaced.Substring(0, 1).ToUpper() + $Spaced.Substring(1) } else { "$PropertyName" } + } + $FriendlyValue = { + param($PropertyName, $Value) + if ($Value -is [bool]) { return $Value } # the template renders On/Off + $Text = "$Value" + $Option = @($SettingOptions["$PropertyName"]) | Where-Object { "$($_.value)" -eq $Text } | Select-Object -First 1 + if ($Option) { return "$($Option.label)" } + if ($Text.Length -gt 200) { return "$($Text.Substring(0, 197))..." } + $Text + } + $Differences = @(($Result.Diff ?? $Result.RowDiff) | Where-Object { $_ } | Select-Object -First 12 | ForEach-Object { + [PSCustomObject]@{ + Property = & $FriendlySetting $_.Property + ExpectedValue = & $FriendlyValue $_.Property $_.ExpectedValue + ReceivedValue = & $FriendlyValue $_.Property $_.ReceivedValue + } + }) + + $RunId = if ($script:CippBaselineRunIdStorage) { $script:CippBaselineRunIdStorage.Value } else { '' } + $Table = Get-CippTable -tablename 'BaselineAlertQueue' + Add-CIPPAzDataTableEntity @Table -Entity @{ + PartitionKey = "$($Item.TenantFilter)" + RowKey = [string](New-Guid).Guid + Event = "$($Result.AlertEvent)" + Standard = "$($Item.Standard)" + Label = "$Label" + Description = "$Description" + Category = "$($Definition.cat)" + Baseline = "$($Item.SourceTemplate)" + Stage = "$($Item.Stage)" + Differences = "$(ConvertTo-Json -Compress -Depth 10 -InputObject $Differences)" + ConflictWith = "$(@($Item.ConflictWith) -join ', ')" + AlertEmails = "$($Item.AlertEmails)" + AlertWebhookUrl = "$($Item.AlertWebhookUrl)" + RunId = "$RunId" } } catch { - Write-LogMessage -API 'Baselines' -tenant $Item.TenantFilter -message "Failed to send baseline alert for $($Item.Standard): $($_.Exception.Message)" -Sev 'Error' + Write-LogMessage -API 'Baselines' -tenant $Result.Item.TenantFilter -message "Failed to queue baseline alert for $($Result.Item.Standard): $($_.Exception.Message)" -Sev 'Error' } } diff --git a/Modules/CIPPCore/Public/Baselines/Send-CIPPBaselineAlertDigest.ps1 b/Modules/CIPPCore/Public/Baselines/Send-CIPPBaselineAlertDigest.ps1 new file mode 100644 index 0000000000000..3033bb1a98452 --- /dev/null +++ b/Modules/CIPPCore/Public/Baselines/Send-CIPPBaselineAlertDigest.ps1 @@ -0,0 +1,100 @@ +function Send-CIPPBaselineAlertDigest { + <# + .SYNOPSIS + Ships all queued baseline alerts as one notification per tenant. + .DESCRIPTION + + .FUNCTIONALITY + Internal + #> + [CmdletBinding()] + param() + + try { + $Table = Get-CippTable -tablename 'BaselineAlertQueue' + $Queued = @(Get-CIPPAzDataTableEntity @Table) + if ($Queued.Count -eq 0) { return } + + $ConfigTable = Get-CIPPTable -TableName SchedulerConfig + $Config = [pscustomobject](Get-CIPPAzDataTableEntity @ConfigTable -Filter "RowKey eq 'CippNotifications' and PartitionKey eq 'CippNotifications'") + + $CippConfigTable = Get-CippTable -tablename Config + $CippConfig = Get-CIPPAzDataTableEntity @CippConfigTable -Filter "PartitionKey eq 'InstanceProperties' and RowKey eq 'CIPPURL'" + $CIPPURL = 'https://{0}' -f $CippConfig.Value + + foreach ($TenantGroup in ($Queued | Group-Object -Property PartitionKey)) { + $TenantFilter = $TenantGroup.Name + + # Newest event wins when the same (standard, event) queued twice across runs. + $Rows = @($TenantGroup.Group | Sort-Object -Property Timestamp -Descending | + Group-Object -Property { '{0}|{1}' -f $_.Standard, $_.Event } | + ForEach-Object { $_.Group[0] }) + + # A baseline with its own destinations ships separately from the default + # channels, so one digest per destination set. + foreach ($DestinationGroup in ($Rows | Group-Object -Property { '{0}|{1}' -f $_.AlertEmails, $_.AlertWebhookUrl })) { + $GroupRows = @($DestinationGroup.Group) + $AlertEmails = "$($GroupRows[0].AlertEmails)" + $AlertWebhookUrl = "$($GroupRows[0].AlertWebhookUrl)" + $HasOverride = -not ([string]::IsNullOrWhiteSpace($AlertEmails) -and [string]::IsNullOrWhiteSpace($AlertWebhookUrl)) + + $Alerts = @($GroupRows | ForEach-Object { + [PSCustomObject]@{ + Event = $_.Event + Standard = $_.Standard + Label = $_.Label + Description = $_.Description + Category = $_.Category + Baseline = $_.Baseline + Stage = $_.Stage + Differences = @($_.Differences | ConvertFrom-Json -ErrorAction SilentlyContinue) + ConflictWith = @("$($_.ConflictWith)" -split ', ' | Where-Object { $_ }) + RunId = $_.RunId + } + }) + + $EmailTemplate = New-CIPPAlertTemplate -Format 'html' -InputObject 'baseline' -Data $Alerts -CIPPURL $CIPPURL -Tenant $TenantFilter + $Title = $EmailTemplate.title + + $WebhookData = [PSCustomObject]@{ + Title = $Title + Tenant = $TenantFilter + DriftCount = @($Alerts | Where-Object { $_.Event -eq 'Drift' }).Count + RemediatedCount = @($Alerts | Where-Object { $_.Event -eq 'Remediated' }).Count + ConflictCount = @($Alerts | Where-Object { $_.Event -eq 'Conflict' }).Count + Standards = @($Alerts | Select-Object Event, Standard, Label, Category, Baseline, Stage, Differences, ConflictWith, RunId) + } | ConvertTo-Json -Depth 10 -Compress + + if ($HasOverride) { + if (![string]::IsNullOrWhiteSpace($AlertEmails)) { + $null = Send-CIPPAlert -Type 'email' -Title $Title -HTMLContent $EmailTemplate.htmlcontent -TenantFilter $TenantFilter -altEmail $AlertEmails -APIName 'Baselines' + } + if (![string]::IsNullOrWhiteSpace($AlertWebhookUrl)) { + $null = Send-CIPPAlert -Type 'webhook' -Title $Title -JSONContent $WebhookData -TenantFilter $TenantFilter -altWebhook $AlertWebhookUrl ` + -APIName 'Baselines' -SchemaSource 'Baseline Alert' -InvokingCommand 'Invoke-CIPPBaselineStandard' ` + -UseStandardizedSchema:$([boolean]$Config.UseStandardizedSchema) + } + } else { + $null = Send-CIPPAlert -Type 'email' -Title $Title -HTMLContent $EmailTemplate.htmlcontent -TenantFilter $TenantFilter -APIName 'Baselines' + $null = Send-CIPPAlert -Type 'webhook' -Title $Title -JSONContent $WebhookData -TenantFilter $TenantFilter ` + -APIName 'Baselines' -SchemaSource 'Baseline Alert' -InvokingCommand 'Invoke-CIPPBaselineStandard' ` + -UseStandardizedSchema:$([boolean]$Config.UseStandardizedSchema) + # Gate here so Send-CIPPAlert's skip warning only fires for deliveries someone asked for. + if ($Config.sendtoIntegration) { + # PSA tickets get the style-free fragment: Halo stores the full email's + #