diff --git a/package.json b/package.json
index 5476801e962e..70dece3396fc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "cipp",
- "version": "10.8.3",
+ "version": "10.8.4",
"author": "CIPP Contributors",
"homepage": "https://cipp.app/",
"bugs": {
diff --git a/public/version.json b/public/version.json
index 682cb83714dc..bfb273510e9e 100644
--- a/public/version.json
+++ b/public/version.json
@@ -1,3 +1,3 @@
{
- "version": "10.8.3"
+ "version": "10.8.4"
}
\ No newline at end of file
diff --git a/src/components/BECRemediationReportButton.js b/src/components/BECRemediationReportButton.js
index bd0255fdb1fc..dd67d546cb18 100644
--- a/src/components/BECRemediationReportButton.js
+++ b/src/components/BECRemediationReportButton.js
@@ -77,18 +77,46 @@ export const BECRemediationReportDocument = ({
newUsers: becData?.NewUsers?.length || 0,
newApps: becData?.AddedApps?.length || 0,
permissionChanges: becData?.MailboxPermissionChanges?.length || 0,
+ permissionChangesTargetingUser: (becData?.MailboxPermissionChanges || []).filter(
+ (change) => change?.TargetsSuspect === true
+ ).length,
mfaDevices: becData?.MFADevices?.length || 0,
passwordChanges: becData?.ChangedPasswords?.length || 0,
+ sentMessages: becData?.SentMessages?.length || 0,
trustedSenders: becData?.TrustedSenders?.length || 0,
blockedSenders: becData?.BlockedSenders?.length || 0,
safelistChanges: becData?.SafelistChanges?.length || 0,
+ sharingChanges: becData?.SharingChanges?.length || 0,
+ anonymousLinks: (becData?.SharingChanges || []).filter((c) =>
+ c?.Operation?.startsWith('AnonymousLink')
+ ).length,
intuneDevices: becData?.IntuneDevices?.length || 0,
+ signIns: becData?.SuspectUserSignIns?.length || 0,
+ sentTotalMessages: becData?.SentMessageAnalysis?.TotalMessages ?? 0,
+ sentTotalRecipients: becData?.SentMessageAnalysis?.TotalRecipients ?? 0,
+ repeatedSubjects: becData?.SentMessageAnalysis?.FlaggedSubjectCount || 0,
+ sendBursts: becData?.SentMessageAnalysis?.Bursts?.length || 0,
+ massMailFlagged: becData?.SentMessageAnalysis?.Flagged === true,
+ maliciousApps:
+ (becData?.AddedApps || []).filter((app) => app?.MaliciousMatch).length +
+ (becData?.MaliciousSPs?.length || 0),
}
- const intuneWindowStart = (() => {
+ const locationAnalysis = becData?.LocationAnalysis
+ stats.foreignSignIns = locationAnalysis?.ForeignSignInCount || 0
+ stats.foreignSuccessfulSignIns = locationAnalysis?.ForeignSuccessfulSignInCount || 0
+ stats.foreignSentMessages = locationAnalysis?.ForeignSentMessageCount || 0
+ stats.foreignActivity =
+ (locationAnalysis?.ForeignRuleChangeCount || 0) +
+ (locationAnalysis?.ForeignSafelistChangeCount || 0) +
+ (locationAnalysis?.ForeignSharingChangeCount || 0) +
+ (locationAnalysis?.ForeignSentMessageCount || 0)
+
+ // the analysis window: 7 days before the data was extracted
+ const analysisWindowStart = (() => {
const extractedAt = becData?.ExtractedAt ? new Date(becData.ExtractedAt) : new Date()
if (Number.isNaN(extractedAt.getTime())) {
- return new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
+ return new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000)
}
return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000)
})()
@@ -97,10 +125,23 @@ export const BECRemediationReportDocument = ({
if (!device?.enrolledDateTime) return false
const enrolled = new Date(device.enrolledDateTime)
if (Number.isNaN(enrolled.getTime())) return false
- return enrolled >= intuneWindowStart
+ return enrolled >= analysisWindowStart
})
stats.recentIntuneDevices = recentIntuneDevices.length
+ const isRecentMfaDevice = (method) => {
+ if (!method?.createdDateTime) return false
+ const created = new Date(method.createdDateTime)
+ if (Number.isNaN(created.getTime())) return false
+ return created >= analysisWindowStart
+ }
+ stats.recentMfaDevices = (becData?.MFADevices || []).filter(isRecentMfaDevice).length
+
+ // successful foreign sign-ins first - they prove access, failed ones are mostly spray noise
+ const foreignSignIns = (becData?.SuspectUserSignIns || [])
+ .filter((signIn) => signIn?.ForeignLocation === true)
+ .sort((a, b) => (b?.Status === 'Success') - (a?.Status === 'Success'))
+
const sortedIntuneDevices = [...(becData?.IntuneDevices || [])].sort((a, b) => {
const aTime = a?.enrolledDateTime ? new Date(a.enrolledDateTime).getTime() : 0
const bTime = b?.enrolledDateTime ? new Date(b.enrolledDateTime).getTime() : 0
@@ -112,8 +153,12 @@ export const BECRemediationReportDocument = ({
let threatScore = 0
if (stats.newRules > 0) threatScore += 3
if (stats.ruleChanges > 0) threatScore += 3
- if (stats.permissionChanges > 0) threatScore += 2
- if (stats.newApps > 0) threatScore += 2
+ // A change to this mailbox's permissions outweighs unrelated tenant churn, which the
+ // tenant-wide search also surfaces
+ if (stats.permissionChangesTargetingUser > 0) threatScore += 2
+ else if (stats.permissionChanges > 0) threatScore += 1
+ // Generic new service principals appear constantly; the actually-bad ones score +5 below
+ if (stats.newApps > 0) threatScore += 1
if (stats.newUsers > 5) threatScore += 1
if (stats.safelistChanges > 0) threatScore += 2
@@ -121,6 +166,20 @@ export const BECRemediationReportDocument = ({
const hasSuspiciousRules = becData?.NewRules?.some((rule) => rule.MoveToFolder?.includes('RSS'))
if (hasSuspiciousRules) threatScore += 5
+ // A catalog-matched application is a confirmed bad indicator, not a heuristic
+ if (stats.maliciousApps > 0) threatScore += 5
+ // Only a successful foreign sign-in proves access - failed foreign attempts are
+ // password-spray background noise present on almost every tenant
+ if (stats.foreignSuccessfulSignIns > 0) threatScore += 3
+ if (stats.foreignActivity > 0) threatScore += 3
+ // An anonymous link exposes data to anyone holding the URL, past any later reset
+ if (stats.anonymousLinks > 0) threatScore += 3
+ // Repeated-subject campaigns and send bursts are how a compromised mailbox spreads
+ if (stats.massMailFlagged) threatScore += 3
+ // Persistence moves during the window: a fresh MFA method or device enrollment
+ if (stats.recentMfaDevices > 0) threatScore += 2
+ if (stats.recentIntuneDevices > 0) threatScore += 2
+
if (threatScore >= 7) return { level: 'High', color: '#742A2A' }
if (threatScore >= 4) return { level: 'Medium', color: '#744210' }
return { level: 'Low', color: '#22543D' }
@@ -164,7 +223,7 @@ export const BECRemediationReportDocument = ({
{userData?.userPrincipalName} within{' '}
{tenantName}. The investigation analyzed
suspicious activity indicators including mailbox rules, permission changes, new
- applications, and authentication patterns over a 7-day period.
+ applications, authentication patterns, and sign-in locations over a 7-day period.
@@ -182,8 +241,8 @@ export const BECRemediationReportDocument = ({
stats={[
{ value: stats.newRules, label: 'Mailbox Rules' },
{ value: stats.permissionChanges, label: 'Permission Changes' },
- { value: stats.newApps, label: 'New Applications' },
- { value: stats.newUsers, label: 'New Users' },
+ { value: stats.foreignSignIns, label: 'Foreign Sign-ins' },
+ { value: stats.maliciousApps, label: 'Malicious Apps' },
]}
/>
@@ -202,6 +261,10 @@ export const BECRemediationReportDocument = ({
Last 7 days ending {becData?.ExtractedAt ? formatDate(becData.ExtractedAt) : 'N/A'}
+
+ {locationAnalysis?.UsageLocation ||
+ 'Not assigned - sign-ins and activity could not be compared against an expected country'}
+
@@ -279,7 +342,7 @@ export const BECRemediationReportDocument = ({
{stats.newRules > 0 && (
<>
-
+
The following mailbox rules were detected. Review each rule carefully to determine
if it was created by the user or by an attacker. Rules that forward emails or move
them to unusual folders are particularly suspicious.
@@ -304,7 +367,7 @@ export const BECRemediationReportDocument = ({
)}
{stats.ruleChanges > 0 && (
<>
-
+
The audit log recorded inbox rules being created, changed or removed on this
mailbox. Rules that were removed after use are a common way for attackers to cover
their tracks.
@@ -315,6 +378,10 @@ export const BECRemediationReportDocument = ({
Date: {change.Date || 'Unknown'}
{'\n'}
By: {change.UserKey || 'Unknown'}
+ {change.ClientIP &&
+ `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`}
+ {change.ForeignLocation === true &&
+ '\n⚠️ Originated outside the assigned usage location'}
{change.Parameters && `\nParameters: ${change.Parameters}`}
))}
@@ -327,7 +394,7 @@ export const BECRemediationReportDocument = ({
>
)}
{stats.newRules === 0 && stats.ruleChanges === 0 && (
-
+
No mailbox rules were detected that match suspicious patterns. This is a positive
indicator.
@@ -347,7 +414,7 @@ export const BECRemediationReportDocument = ({
{stats.newUsers > 0 ? (
<>
-
+
The following users were created in the last 7 days. Verify that each account
creation was authorized and legitimate.
@@ -366,7 +433,7 @@ export const BECRemediationReportDocument = ({
)}
>
) : (
-
+
No new user accounts were created during the analysis period.
)}
@@ -380,9 +447,17 @@ export const BECRemediationReportDocument = ({
files without the user's explicit knowledge.
+ {stats.maliciousApps > 0 && (
+
+ One or more applications in this tenant match the CIPP known-malicious application
+ catalog. Consent-based access survives a password reset, so these applications
+ should be removed unless their presence is explained.
+
+ )}
+
{stats.newApps > 0 ? (
<>
-
+
New applications were granted access during the analysis period. Review each
application to ensure it was authorized and is from a trusted publisher.
@@ -394,6 +469,12 @@ export const BECRemediationReportDocument = ({
App ID: {app.appId || 'N/A'}
{'\n'}
Created: {formatDate(app.createdDateTime)}
+ {app.MaliciousMatch &&
+ `\n⚠️ Matches known-malicious catalog entry "${app.MaliciousMatch.Name}"${
+ app.MaliciousMatch.Categories?.length
+ ? ` (${app.MaliciousMatch.Categories.join(', ')})`
+ : ''
+ }`}
))}
{becData.AddedApps.length > 6 && (
@@ -403,15 +484,41 @@ export const BECRemediationReportDocument = ({
)}
>
) : (
-
- No new applications were authorized during the analysis period.
-
+ (becData?.MaliciousSPs?.length || 0) === 0 && (
+
+ No new applications were authorized during the analysis period, and no known
+ malicious applications are present in the tenant.
+
+ )
+ )}
+
+ {(becData?.MaliciousSPs?.length || 0) > 0 && (
+ <>
+ {becData.MaliciousSPs.slice(0, 6).map((app, index) => (
+
+ Catalog entry: {app.CatalogName || 'Unknown'}
+ {'\n'}
+ App ID: {app.appId || 'N/A'}
+ {'\n'}
+ Categories: {app.Categories?.length ? app.Categories.join(', ') : 'N/A'}
+ {'\n'}
+ Enabled: {String(app.accountEnabled ?? 'Unknown')}
+ {'\n'}
+ First seen: {formatDate(app.createdDateTime)}
+
+ ))}
+ {becData.MaliciousSPs.length > 6 && (
+
+ ... and {becData.MaliciousSPs.length - 6} more (see JSON export for full list)
+
+ )}
+ >
)}
- {/* CHECK 4, 5, 6: PERMISSIONS, MFA, PASSWORDS */}
-
+ {/* CHECK 4, 5, 6, 7: PERMISSIONS, SENT MAIL, MFA, PASSWORDS */}
+
{/* Check 4: Mailbox Permission Changes */}
@@ -423,7 +530,7 @@ export const BECRemediationReportDocument = ({
{stats.permissionChanges > 0 ? (
<>
-
+
Mailbox permission changes were detected. Verify that each change was authorized
and necessary for legitimate business purposes.
@@ -435,6 +542,8 @@ export const BECRemediationReportDocument = ({
Target: {change.ObjectId || 'N/A'}
{'\n'}
Permissions: {change.Permissions || 'Unknown'}
+ {change.TargetsSuspect === true &&
+ '\n⚠️ Targets the investigated mailbox'}
))}
{becData.MailboxPermissionChanges.length > 5 && (
@@ -444,14 +553,107 @@ export const BECRemediationReportDocument = ({
)}
>
) : (
-
+
No mailbox permission changes were detected during the analysis period.
)}
- {/* Check 5: MFA Devices */}
-
+ {/* Check 5: Sent Messages */}
+
+
+ Attackers use a compromised mailbox to send fraudulent invoices, phishing, or
+ internal impersonation mail. The message trace shows what actually left the mailbox
+ during the analysis period, including the IP address it was sent from.
+
+
+ {stats.sentMessages > 0 ? (
+ <>
+
+ ℹ️ {stats.sentTotalMessages || stats.sentMessages} message(s) to{' '}
+ {stats.sentTotalRecipients || stats.sentMessages} recipient(s) were sent by this
+ mailbox during the analysis period
+ {stats.foreignSentMessages > 0
+ ? `, including ${stats.foreignSentMessages} from an IP outside the user's assigned usage location.`
+ : '.'}
+
+
+ {stats.massMailFlagged && (
+
+ {stats.repeatedSubjects > 0
+ ? `${stats.repeatedSubjects} subject(s) were sent as many separate messages or to many recipients. `
+ : ''}
+ {stats.sendBursts > 0
+ ? `${stats.sendBursts} short burst(s) of high-volume sending were detected. `
+ : ''}
+ Identical-subject mass mail and send bursts are how a compromised mailbox
+ spreads phishing or fraudulent invoices. Review the campaigns below and warn
+ the recipients if the content was malicious.
+
+ )}
+
+ {(becData?.SentMessageAnalysis?.RepeatedSubjects || [])
+ .slice(0, 5)
+ .map((group, index) => (
+
+ Messages: {group.MessageCount}
+ {'\n'}
+ Recipients: {group.RecipientCount}
+ {'\n'}
+ First sent: {group.FirstSent || 'N/A'}
+ {'\n'}
+ Last sent: {group.LastSent || 'N/A'}
+
+ ))}
+ {(becData?.SentMessageAnalysis?.RepeatedSubjects?.length || 0) > 5 && (
+
+ ... and {becData.SentMessageAnalysis.RepeatedSubjects.length - 5} more repeated
+ subjects (see JSON export for full list)
+
+ )}
+
+ {(becData?.SentMessageAnalysis?.Bursts || []).slice(0, 5).map((burst, index) => (
+
+ Starting: {burst.WindowStart || 'N/A'}
+ {burst.TopSubject && `\nMost common subject: ${burst.TopSubject}`}
+
+ ))}
+ {(becData?.SentMessageAnalysis?.Bursts?.length || 0) > 5 && (
+
+ ... and {becData.SentMessageAnalysis.Bursts.length - 5} more bursts (see JSON
+ export for full list)
+
+ )}
+
+ {becData.SentMessages.slice(0, 10).map((msg, index) => (
+
+ To: {msg.RecipientAddress || 'N/A'}
+ {'\n'}
+ Status: {msg.Status || 'N/A'}
+ {'\n'}
+ Received: {msg.Received || 'N/A'}
+ {msg.FromIP &&
+ `\nFrom IP: ${msg.FromIP}${msg.Country ? ` (${msg.Country})` : ''}`}
+ {msg.ForeignLocation === true &&
+ '\n⚠️ Sent from outside the assigned usage location'}
+
+ ))}
+ {becData.SentMessages.length > 10 && (
+
+ ... and {becData.SentMessages.length - 10} more messages (see JSON export for
+ full list)
+
+ )}
+ >
+ ) : (
+
+ No messages were sent by this mailbox during the analysis period.
+
+ )}
+
+
+ {/* Check 6: MFA Devices */}
+
Multi-factor authentication (MFA) devices provide an additional layer of security.
Reviewing registered MFA methods helps identify if attackers have added unauthorized
@@ -461,28 +663,42 @@ export const BECRemediationReportDocument = ({
{stats.mfaDevices > 0 ? (
<>
- ℹ {stats.mfaDevices} MFA device(s) registered. Verify each device belongs to the
- user.
+ ℹ️ {stats.mfaDevices} MFA device(s) registered
+ {stats.recentMfaDevices > 0
+ ? `, including ${stats.recentMfaDevices} registered in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.`
+ : '. Verify each device belongs to the user.'}
- {becData.MFADevices.slice(0, 5).map((device, index) => (
-
- Display Name: {device.displayName || 'N/A'}
- {'\n'}
- Registered: {formatDate(device.createdDateTime)}
-
- ))}
+ {[...becData.MFADevices]
+ .sort(
+ (a, b) => new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0)
+ )
+ .slice(0, 5)
+ .map((device, index) => (
+
+ Display Name: {device.displayName || 'N/A'}
+ {'\n'}
+ Registered: {formatDate(device.createdDateTime)}
+ {isRecentMfaDevice(device) && '\n⚠️ Registered in the last 7 days'}
+
+ ))}
+ {becData.MFADevices.length > 5 && (
+
+ ... and {becData.MFADevices.length - 5} more methods (see JSON export for full
+ list)
+
+ )}
>
) : (
-
+
No multi-factor authentication devices are registered. MFA is highly recommended to
prevent unauthorized access.
)}
- {/* Check 6: Password Changes */}
-
+ {/* Check 7: Password Changes */}
+
Attackers often change passwords to lock out legitimate users. Reviewing recent
password changes in the tenant helps identify if the compromised account's password
@@ -492,7 +708,7 @@ export const BECRemediationReportDocument = ({
{stats.passwordChanges > 0 ? (
<>
- ℹ {stats.passwordChanges} password change(s) detected in the tenant during the
+ ℹ️ {stats.passwordChanges} password change(s) detected in the tenant during the
analysis period.
@@ -503,16 +719,26 @@ export const BECRemediationReportDocument = ({
Last Password Change: {formatDate(user.lastPasswordChangeDateTime)}
))}
+ {becData.ChangedPasswords.length > 5 && (
+
+ ... and {becData.ChangedPasswords.length - 5} more (see JSON export for full
+ list)
+
+ )}
>
) : (
- ℹ No password changes detected during the analysis period.
+ ℹ️ No password changes detected during the analysis period.
)}
+
+
+ {/* CHECK 8, 9, 10: SENDER LISTS, DEVICES, LOCATIONS */}
+
- {/* Check 7: Trusted & Blocked Senders */}
-
+ {/* Check 8: Trusted & Blocked Senders */}
+
Attackers may add their own domain to the Trusted Senders list so their fraudulent
messages bypass spam filtering, or add finance/security domains to the Blocked
@@ -520,9 +746,17 @@ export const BECRemediationReportDocument = ({
folder.
+ {becData?.SafelistError && (
+
+ {becData.SafelistError}
+ {'\n'}
+ An empty list here does not mean the mailbox has no trusted or blocked senders.
+
+ )}
+
{stats.safelistChanges > 0 && (
<>
-
+
The audit log recorded changes to the Trusted/Blocked Senders and Domains list on
this mailbox. Review each change carefully.
@@ -530,32 +764,57 @@ export const BECRemediationReportDocument = ({
{becData.SafelistChanges.slice(0, 10).map((change, index) => (
Date: {formatDate(change.Date)}
+ {change.ClientIP &&
+ `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`}
+ {change.ForeignLocation === true &&
+ '\n⚠️ Originated outside the assigned usage location'}
{'\n'}
Trusted: {formatSafelistValue(change.Trusted)}
{'\n'}
Blocked: {formatSafelistValue(change.Blocked)}
))}
+ {becData.SafelistChanges.length > 10 && (
+
+ ... and {becData.SafelistChanges.length - 10} more changes (see JSON export for
+ full list)
+
+ )}
>
)}
{stats.trustedSenders > 0 && (
{becData.TrustedSenders.slice(0, 15).join(', ')}
)}
+ {stats.trustedSenders > 15 && (
+
+ ... and {stats.trustedSenders - 15} more trusted entries (see JSON export for full
+ list)
+
+ )}
{stats.blockedSenders > 0 && (
{becData.BlockedSenders.slice(0, 15).join(', ')}
)}
-
- {stats.trustedSenders === 0 && stats.blockedSenders === 0 && stats.safelistChanges === 0 && (
-
- No trusted or blocked sender/domain entries were found on this mailbox.
-
+ {stats.blockedSenders > 15 && (
+
+ ... and {stats.blockedSenders - 15} more blocked entries (see JSON export for full
+ list)
+
)}
+
+ {!becData?.SafelistError &&
+ stats.trustedSenders === 0 &&
+ stats.blockedSenders === 0 &&
+ stats.safelistChanges === 0 && (
+
+ No trusted or blocked sender/domain entries were found on this mailbox.
+
+ )}
- {/* Check 8: Intune Devices */}
-
+ {/* Check 9: Intune Devices */}
+
Newly enrolled Intune devices can indicate an attacker standing up a VM or BYOD
endpoint under the compromised identity, including paths that re-register Windows
@@ -563,7 +822,7 @@ export const BECRemediationReportDocument = ({
{becData?.IntuneDevicesError ? (
-
+
{becData.IntuneDevicesError}
{'\n'}
An empty device list here does not mean the user has no Intune devices.
@@ -571,7 +830,7 @@ export const BECRemediationReportDocument = ({
) : stats.intuneDevices > 0 ? (
<>
- ℹ {stats.intuneDevices} Intune-managed device(s) associated with this user
+ ℹ️ {stats.intuneDevices} Intune-managed device(s) associated with this user
{stats.recentIntuneDevices > 0
? `, including ${stats.recentIntuneDevices} enrolled in the last 7 days.`
: '. None were enrolled in the last 7 days.'}
@@ -590,13 +849,142 @@ export const BECRemediationReportDocument = ({
{device.serialNumber ? `\nSerial: ${device.serialNumber}` : ''}
))}
+ {sortedIntuneDevices.length > 5 && (
+
+ ... and {sortedIntuneDevices.length - 5} more devices (see JSON export for full
+ list)
+
+ )}
>
) : (
-
+
No Intune-managed devices were found for this user.
)}
+
+ {/* Check 10: Sign-in Locations */}
+
+
+ Sign-ins from countries the user does not work from are one of the strongest
+ compromise indicators. Each sign-in is compared against the user's assigned usage
+ location in Entra ID
+ {locationAnalysis?.UsageLocation ? ` (${locationAnalysis.UsageLocation})` : ''}, and
+ the client IPs behind rule changes, safelist changes, sharing changes, and sent mail
+ are geo-located and compared the same way.
+
+
+ {becData?.SuspectUserSignInsError ? (
+
+ {becData.SuspectUserSignInsError}
+ {'\n'}
+ An empty list here does not mean the user has not signed in.
+
+ ) : (
+ <>
+ {!locationAnalysis?.UsageLocation && (
+
+ {locationAnalysis?.Note ||
+ 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'}
+
+ )}
+
+ {(locationAnalysis?.SignInCountries?.length || 0) > 0 && (
+
+ {locationAnalysis.SignInCountries.map(
+ (c) => `${c.Country}: ${c.Count} sign-in(s)`
+ ).join('\n')}
+
+ )}
+
+ {stats.foreignSignIns > 0 || stats.foreignActivity > 0 ? (
+ <>
+
+ {stats.foreignSignIns} sign-in(s) (of which {stats.foreignSuccessfulSignIns}{' '}
+ succeeded), {locationAnalysis?.ForeignRuleChangeCount || 0} inbox rule
+ change(s), {locationAnalysis?.ForeignSafelistChangeCount || 0} safelist
+ change(s), {locationAnalysis?.ForeignSharingChangeCount || 0} sharing
+ change(s), and {locationAnalysis?.ForeignSentMessageCount || 0} sent
+ message(s) originated outside {locationAnalysis?.UsageLocation}. Failed
+ foreign sign-ins are mostly password-spray noise; the successful ones prove
+ access. Review each carefully — a single legitimate trip can explain some of
+ this, but rule, safelist, or sharing changes from a foreign IP rarely have an
+ innocent explanation.
+
+
+ {foreignSignIns.slice(0, 10).map((signIn, index) => (
+
+ Application: {signIn.AppDisplayName || 'N/A'}
+ {'\n'}
+ IP Address: {signIn.IPAddress || 'N/A'}
+ {'\n'}
+ City: {signIn.City || 'N/A'}
+ {'\n'}
+ Result: {signIn.Status || 'N/A'}
+
+ ))}
+ {foreignSignIns.length > 10 && (
+
+ ... and {foreignSignIns.length - 10} more foreign sign-ins (see JSON export
+ for full list)
+
+ )}
+ >
+ ) : locationAnalysis?.UsageLocation ? (
+
+ All located sign-ins and activity match the user's assigned usage location (
+ {locationAnalysis.UsageLocation}).
+
+ ) : null}
+ >
+ )}
+
+
+ {/* Check 11: Sharing Links */}
+
+
+ Attackers share OneDrive and SharePoint folders to give themselves a data feed that
+ survives a password reset, and anonymous links expose the content to anyone holding
+ the URL. This check lists every sharing link the account created or changed during
+ the analysis period, including the IP address it was done from.
+
+
+ {stats.sharingChanges > 0 ? (
+ <>
+
+ {stats.anonymousLinks > 0
+ ? `${stats.anonymousLinks} of these involve anonymous links, which anyone with the URL can open. `
+ : ''}
+ Review each link and remove any that are not explained, even if the account has
+ since been remediated.
+
+
+ {becData.SharingChanges.slice(0, 10).map((change, index) => (
+
+ Date: {formatDate(change.Date)}
+ {'\n'}
+ Workload: {change.Workload || 'N/A'}
+ {change.Target && `\nShared with: ${change.Target}`}
+ {change.ClientIP &&
+ `\nFrom: ${change.ClientIP}${change.Country ? ` (${change.Country})` : ''}`}
+ {change.ForeignLocation === true &&
+ '\n⚠️ Originated outside the assigned usage location'}
+
+ ))}
+ {becData.SharingChanges.length > 10 && (
+
+ ... and {becData.SharingChanges.length - 10} more changes (see JSON export for
+ full list)
+
+ )}
+ >
+ ) : (
+
+ No sharing links were created or changed by this account during the analysis
+ period.
+
+ )}
+
{/* RECOMMENDATIONS PAGE */}
@@ -736,6 +1124,8 @@ export const BECRemediationReportDocument = ({
{'\n'}
Analysis Period: 7 days
{'\n'}
+ Assigned Usage Location: {locationAnalysis?.UsageLocation || 'Not assigned'}
+ {'\n'}
Audit Log Status: {becData?.ExtractResult || 'Unknown'}
@@ -746,14 +1136,25 @@ export const BECRemediationReportDocument = ({
{'\n'}
Rule Changes: {stats.ruleChanges}
{'\n'}
- Permission Changes: {stats.permissionChanges}
+ Permission Changes: {stats.permissionChanges} ({stats.permissionChangesTargetingUser}{' '}
+ targeting this mailbox)
{'\n'}
New Applications: {stats.newApps}
{'\n'}
+ Known-Malicious Applications: {stats.maliciousApps}
+ {'\n'}
New Users: {stats.newUsers}
{'\n'}
+ Sent Messages: {stats.sentTotalMessages || stats.sentMessages}
+ {'\n'}
+ Repeated Subject Campaigns: {stats.repeatedSubjects}
+ {'\n'}
+ Send Bursts: {stats.sendBursts}
+ {'\n'}
MFA Devices: {stats.mfaDevices}
{'\n'}
+ Recent MFA Registrations (7d): {stats.recentMfaDevices}
+ {'\n'}
Password Changes: {stats.passwordChanges}
{'\n'}
Trusted Senders: {stats.trustedSenders}
@@ -762,9 +1163,17 @@ export const BECRemediationReportDocument = ({
{'\n'}
Safelist Changes: {stats.safelistChanges}
{'\n'}
+ Sharing Changes: {stats.sharingChanges}
+ {'\n'}
+ Anonymous Links: {stats.anonymousLinks}
+ {'\n'}
Intune Devices: {stats.intuneDevices}
{'\n'}
Recent Intune Enrollments (7d): {stats.recentIntuneDevices}
+ {'\n'}
+ Foreign Sign-ins: {stats.foreignSignIns} ({stats.foreignSuccessfulSignIns} successful)
+ {'\n'}
+ Foreign Rule/Safelist/Sharing/Mail Activity: {stats.foreignActivity}
diff --git a/src/components/CippAllTenants/useAllTenantsDashboard.js b/src/components/CippAllTenants/useAllTenantsDashboard.js
index 5d3882102119..09426e63bb0f 100644
--- a/src/components/CippAllTenants/useAllTenantsDashboard.js
+++ b/src/components/CippAllTenants/useAllTenantsDashboard.js
@@ -118,17 +118,20 @@ export const useAllTenantsDashboard = () => {
waiting: true,
})
+ // summary returns the estate roll-up only. The row list is one entry per tenant per standard, and
+ // this card renders four bucket counts, an average, four low scorers and two pending totals.
const alignmentApi = ApiGetCall({
url: '/api/ListTenantAlignment',
+ data: { summary: true },
queryKey: 'AllTenantsDashboard-Alignment',
waiting: true,
})
- // summaryOnly projects away ResultMarkdown/ResultDataJson server-side — this card only counts
- // rows, and those two columns are unbounded blobs that otherwise dominate the payload.
+ // countsOnly returns the aggregates with no rows. Deriving them here pulled the estate's whole
+ // failed-test set over the wire, growing linearly with tenant count.
const failedTestsApi = ApiGetCall({
url: '/api/ListTestResultsTenants',
- data: { status: 'Failed', summaryOnly: 'true' },
+ data: { status: 'Failed', countsOnly: 'true' },
queryKey: 'AllTenantsDashboard-FailedTests',
waiting: true,
})
@@ -226,104 +229,45 @@ export const useAllTenantsDashboard = () => {
/* ---------------------------------------------------------------- alignment */
const alignment = useMemo(() => {
- const rows = asArray(alignmentApi.data)
- const byTenant = new Map()
- let pendingDeviations = 0
- const pendingByTenant = new Map()
-
- // ListTenantAlignment serialises camelCase on the wire even though the PowerShell object that
- // builds it is PascalCase. Accept both so this keeps working if that ever normalises.
- rows.forEach((row) => {
- const key = row?.tenantFilter ?? row?.TenantFilter
- if (!key) return
- const score = Number(
- row?.combinedAlignmentScore ??
- row?.CombinedScore ??
- row?.alignmentScore ??
- row?.AlignmentScore ??
- 0
- )
- const existing = byTenant.get(key) ?? { total: 0, count: 0 }
- byTenant.set(key, {
- total: existing.total + score,
- count: existing.count + 1,
- })
-
- const pending = Number(row?.pendingDeviationsCount ?? row?.PendingDeviationsCount ?? 0)
- if (pending > 0) {
- pendingDeviations += pending
- pendingByTenant.set(key, (pendingByTenant.get(key) ?? 0) + pending)
- }
- })
-
- const scores = []
- byTenant.forEach((value, key) => {
- scores.push({
- tenant: key,
- name: displayNameByDomain.get(key) ?? key,
- score: value.count ? Math.round(value.total / value.count) : 0,
- })
- })
-
- const buckets = { strong: 0, good: 0, weak: 0, poor: 0 }
- scores.forEach(({ score }) => {
- if (score >= 90) buckets.strong += 1
- else if (score >= 75) buckets.good += 1
- else if (score >= 50) buckets.weak += 1
- else buckets.poor += 1
- })
-
- const average = scores.length
- ? Math.round(scores.reduce((sum, item) => sum + item.score, 0) / scores.length)
- : 0
+ const summary = alignmentApi.data ?? {}
+ const buckets = summary.Buckets ?? {}
return {
- scores,
- buckets,
- average,
- lowest: [...scores].sort((a, b) => a.score - b.score).slice(0, 4),
- pendingDeviations,
- pendingTenantCount: pendingByTenant.size,
+ // Only ever read for its length — the endpoint returns the count directly.
+ scores: { length: summary.ScoredTenantCount ?? 0 },
+ buckets: {
+ strong: buckets.Strong ?? 0,
+ good: buckets.Good ?? 0,
+ weak: buckets.Weak ?? 0,
+ poor: buckets.Poor ?? 0,
+ },
+ average: summary.Average ?? 0,
+ lowest: (summary.Lowest ?? []).map((item) => ({
+ tenant: item.Tenant,
+ name: item.Name ?? item.Tenant,
+ score: item.Score ?? 0,
+ })),
+ pendingDeviations: summary.PendingDeviations ?? 0,
+ pendingTenantCount: summary.PendingTenantCount ?? 0,
}
- }, [alignmentApi.data, displayNameByDomain])
+ }, [alignmentApi.data])
/* ------------------------------------------------------------- test results */
const tests = useMemo(() => {
- const rows = asArray(failedTestsApi.data)
- const identityChecks = new Map()
- const highRiskTenants = new Set()
- let high = 0
-
- rows.forEach((row) => {
- if (String(row?.Risk ?? '').toLowerCase() === 'high') {
- high += 1
- if (row?.Tenant) highRiskTenants.add(row.Tenant)
- }
-
- if (String(row?.TestType ?? '').toLowerCase() === 'identity' && row?.Name) {
- const tenantSet = identityChecks.get(row.Name) ?? new Set()
- if (row?.Tenant) tenantSet.add(row.Tenant)
- identityChecks.set(row.Name, tenantSet)
- }
- })
-
- const identityRows = [...identityChecks.entries()]
- .map(([label, tenantSet]) => ({ label, tenantCount: tenantSet.size }))
- .sort((a, b) => b.tenantCount - a.tenantCount)
- .slice(0, 4)
-
- const identityTenantCount = new Set(
- rows
- .filter((row) => String(row?.TestType ?? '').toLowerCase() === 'identity' && row?.Tenant)
- .map((row) => row.Tenant)
- ).size
+ const counts = failedTestsApi.data?.Counts ?? {}
+ const byTestType = counts.ByTestType ?? {}
+ // The facet is keyed by the TestType as stored ('Identity'); match without assuming casing.
+ const identityKey = Object.keys(byTestType).find((key) => key.toLowerCase() === 'identity')
+ const identity = (identityKey ? byTestType[identityKey] : null) ?? {}
return {
- identityRows,
- identityTenantCount,
- high,
- highRiskTenantCount: highRiskTenants.size,
+ identityRows: (identity.TopChecks ?? [])
+ .slice(0, 4)
+ .map((check) => ({ label: check.Name, tenantCount: check.TenantCount })),
+ identityTenantCount: identity.Tenants ?? 0,
+ high: counts.HighRiskFailed ?? 0,
+ highRiskTenantCount: counts.HighRiskTenants ?? 0,
}
}, [failedTestsApi.data])
diff --git a/src/components/CippComponents/CippCAPolicyBuilder.jsx b/src/components/CippComponents/CippCAPolicyBuilder.jsx
index 39e4bc968144..ddbc9b4de2a6 100644
--- a/src/components/CippComponents/CippCAPolicyBuilder.jsx
+++ b/src/components/CippComponents/CippCAPolicyBuilder.jsx
@@ -108,6 +108,114 @@ function SectionHeader({ title, description, requiresLicense, icon }) {
);
}
+/**
+ * The guest / external user block, which Graph models identically on the include and the
+ * exclude side. Rendered twice from UsersSection rather than duplicated.
+ */
+function GuestsOrExternalUsersFields({ formControl, disabled, prefix, direction, typeOptions }) {
+ const base = `${prefix}.${direction}GuestsOrExternalUsers`;
+ const Verb = direction === "include" ? "Include" : "Exclude";
+ const scopeHelp =
+ direction === "include"
+ ? "Choose whether the policy applies to all external tenants or specific ones. Only relevant for external user types (not internal guests)."
+ : "Choose whether the exclusion applies to all external tenants or specific ones. Only relevant for external user types (not internal guests).";
+
+ // Entra rejects an include-guests assignment (error 1119) when Include Users also carries one of
+ // its special values. The exclude side has no such constraint, so only watch on the include side.
+ const includeUsers = useWatch({ control: formControl.control, name: `${prefix}.includeUsers` });
+ const guestTypes = useWatch({ control: formControl.control, name: `${base}.guestOrExternalUserTypes` });
+ const conflictsWithIncludeUsers = useMemo(() => {
+ if (direction !== "include") return false;
+ const hasGuestTypes = Array.isArray(guestTypes) ? guestTypes.length > 0 : Boolean(guestTypes);
+ if (!hasGuestTypes) return false;
+ const users = Array.isArray(includeUsers) ? includeUsers : [includeUsers];
+ return users.some((u) => ["All", "None", "GuestsOrExternalUsers"].includes(u?.value ?? u));
+ }, [direction, guestTypes, includeUsers]);
+
+ return (
+ <>
+
+
+
+ {Verb} Guests or External Users
+
+
+
+
+
+
+ Select one or more external user types to {direction} {direction === "include" ? "in" : "from"} this
+ policy.
+
+ {conflictsWithIncludeUsers && (
+
+ Entra ID rejects this combination. Clear "Include Users" — an include-guests
+ assignment cannot be combined with All, None or GuestsOrExternalUsers.
+
+ )}
+
+
+
+
+
+ {scopeHelp}
+
+
+
+
+
+
+ Enter the tenant IDs to scope this to (e.g. your partner tenant ID for a service
+ provider {direction === "include" ? "inclusion" : "exclusion"}).
+
+
+
+
+ >
+ );
+}
+
// ---------------------------------------------------------------------------
// Users & Groups section
// ---------------------------------------------------------------------------
@@ -215,79 +323,21 @@ function UsersSection({ formControl, disabled, prefix = "conditions.users" }) {
/>
- {/* Guest / External User Exclusions */}
-
-
-
- Exclude Guests or External Users
-
-
-
-
-
-
- Select one or more external user types to exclude from this policy.
-
-
-
-
-
-
- Choose whether the exclusion applies to all external tenants or specific ones. Only
- relevant for external user types (not internal guests).
-
-
-
-
-
-
- Enter the tenant IDs to scope this exclusion to (e.g. your partner tenant ID for
- service provider exclusion).
-
-
-
-
+ disabled={disabled}
+ prefix={prefix}
+ direction="include"
+ typeOptions={guestTypeOpts}
+ />
+
);
}
@@ -305,6 +355,11 @@ function ApplicationsSection({ formControl, disabled, prefix = "conditions.appli
() => enumToOptions(schemaDef?.properties?.includeUserActions),
[schemaDef]
);
+ const filterSchema = resolveRef("#/$defs/conditionalAccessFilter");
+ const filterModeOpts = useMemo(
+ () => enumToOptions(filterSchema?.properties?.mode),
+ [filterSchema]
+ );
return (
@@ -344,6 +399,52 @@ function ApplicationsSection({ formControl, disabled, prefix = "conditions.appli
options={userActionOpts}
/>
+
+
+
+ Used instead of cloud apps. In a template, deployment matches these by display name and
+ creates the authentication context in the tenant if it is missing.
+
+
+
+ {/* Application filter */}
+
+
+
+ Application Filter
+
+
+
+
+
+
+
+
+
);
}
@@ -399,6 +500,17 @@ function ConditionsSection({ formControl, disabled }) {
[locationSchema]
);
+ const clientAppsSchema = resolveRef("#/$defs/conditionalAccessClientApplications");
+ const includeSpOpts = useMemo(
+ () => specialValueOptions(clientAppsSchema?.properties?.includeServicePrincipals),
+ [clientAppsSchema]
+ );
+ const filterSchema = resolveRef("#/$defs/conditionalAccessFilter");
+ const filterModeOpts = useMemo(
+ () => enumToOptions(filterSchema?.properties?.mode),
+ [filterSchema]
+ );
+
return (
{/* Client app types */}
@@ -570,6 +682,67 @@ function ConditionsSection({ formControl, disabled }) {
options={authFlowOpts}
/>
+
+ {/* Workload identities */}
+
+
+
+
+ Workload Identities
+
+
+
+
+
+
+
+
+ Scopes the policy to workload identities instead of users. Leave empty for a user policy.
+
+
+
+
+
+
+
+
+
+
+
);
}
@@ -625,7 +798,10 @@ function GrantControlsSection({ formControl, disabled }) {
? gc.builtInControls.length
: gc.builtInControls) ||
gc.authenticationStrength?.id ||
- (Array.isArray(gc.termsOfUse) ? gc.termsOfUse.length : gc.termsOfUse);
+ (Array.isArray(gc.termsOfUse) ? gc.termsOfUse.length : gc.termsOfUse) ||
+ (Array.isArray(gc.customAuthenticationFactors)
+ ? gc.customAuthenticationFactors.length
+ : gc.customAuthenticationFactors);
if (hasControls && !(value?.value ?? value)) {
return "Grant operator is required when grant controls are set";
}
@@ -676,6 +852,21 @@ function GrantControlsSection({ formControl, disabled }) {
placeholder="Terms of use agreement IDs"
/>
+
+
+
+ Legacy custom controls from an external identity provider, referenced by ID.
+
+
);
}
@@ -1356,6 +1547,9 @@ export default CippCAPolicyBuilder;
* Call this in your form's submit handler to strip out { label, value }
* wrapper objects from autoComplete fields, remove empty/null branches,
* and ensure the JSON is ready to send to AddCAPolicy / AddCATemplate.
+ *
+ * Absent keys are fine: the backend canonicalizer (Format-CIPPCAPolicy) restores every managed
+ * key it needs as its cleared form at deploy/edit time, so this stays a plain payload cleanup.
*/
export function extractCAPolicyJSON(formValues) {
const clean = (obj) => {
@@ -1449,7 +1643,8 @@ export function extractCAPolicyJSON(formValues) {
}
// Post-process: strip session control sub-objects where isEnabled is false.
- // Graph validates fields like `mode` even when disabled — safest to omit entirely.
+ // Graph validates fields like `mode` even when disabled — safest to omit entirely; the backend
+ // canonicalizer turns the resulting absence into the null that clears it on the policy.
if (cleaned.sessionControls) {
const sessionKeys = [
"applicationEnforcedRestrictions",
diff --git a/src/components/CippComponents/CippMultiQueueTracker.jsx b/src/components/CippComponents/CippMultiQueueTracker.jsx
index 4be060760ed3..d1c29443905b 100644
--- a/src/components/CippComponents/CippMultiQueueTracker.jsx
+++ b/src/components/CippComponents/CippMultiQueueTracker.jsx
@@ -48,7 +48,10 @@ export const CippMultiQueueTracker = ({ queueIds = [], relatedQueryKeys = [], la
data: { QueueIds: idKey },
queryKey: `CippQueues-${idKey || 'none'}`,
waiting: ids.length > 0,
- refetchInterval: (data) => (isFinished(data?.Summary?.Status) ? false : 3000),
+ // TanStack Query v5 hands this callback the Query object, not the data. Reading the data
+ // off query.state is what makes the interval actually return false on completion - with
+ // the v4 (data) signature the status is never found and the poll runs forever.
+ refetchInterval: (query) => (isFinished(query?.state?.data?.Summary?.Status) ? false : 3000),
refetchOnWindowFocus: false,
staleTime: 0,
})
diff --git a/src/components/CippPdf/PermissionsReportButton.jsx b/src/components/CippPdf/PermissionsReportButton.jsx
index 6be690765885..e5babd008db3 100644
--- a/src/components/CippPdf/PermissionsReportButton.jsx
+++ b/src/components/CippPdf/PermissionsReportButton.jsx
@@ -226,7 +226,7 @@ export const PermissionsReportDocument = ({
/>
>
) : (
-
+
No site or library grants access to Everyone, Everyone except external users, or All
Users.
@@ -261,7 +261,7 @@ export const PermissionsReportDocument = ({
/>
>
) : (
-
+
No guest or external identity holds a permission on a scanned site or library.
)}
@@ -301,7 +301,7 @@ export const PermissionsReportDocument = ({
/>
>
) : (
-
+
No user or directory group holds Full Control outside a site's Owners group.
)}
@@ -321,7 +321,7 @@ export const PermissionsReportDocument = ({
whether each detachment was intentional and is still needed.
) : (
-
+
Every scanned library takes its permissions from its site, so site-level access
management covers them all.
diff --git a/src/components/CippPdf/ReportDocument.jsx b/src/components/CippPdf/ReportDocument.jsx
index 5ea627587a0b..f5810cd1eaeb 100644
--- a/src/components/CippPdf/ReportDocument.jsx
+++ b/src/components/CippPdf/ReportDocument.jsx
@@ -1,6 +1,6 @@
import { Document } from '@react-pdf/renderer'
import { ReportProvider } from './reportContext'
-import { createReportTheme } from './reportTheme'
+import { applyFooterText, createReportTheme } from './reportTheme'
import { createReportStyles, DEFAULT_PAGE_SETUP } from './reportPdfStyles'
import { CoverPage } from './reportPdfPrimitives'
import { resolveCoverImage } from './resolveCoverImage'
@@ -83,6 +83,14 @@ export const ReportDocument = ({
const context = { theme, styles, variables, logo, footerLabel, size, orientation, date }
+ // Branding's cover note wins; a report's own wording is the fallback. Leave the prop undefined
+ // when neither is set so CoverPage's default confidentiality line still appears. Variables are
+ // filled here so a configured `%tenantname%` note resolves the same way the page footer does.
+ const coverNoteTemplate = theme.coverFooterText || coverFooterNote
+ const coverNote = coverNoteTemplate
+ ? applyFooterText(coverNoteTemplate, variables)
+ : undefined
+
return (
@@ -103,8 +111,7 @@ export const ReportDocument = ({
// Naming the client on the cover is what makes it a client report. Every report wanted
// it and each one printed it slightly differently; `coverTenant={false}` opts out.
tenantName={coverTenant === false ? null : coverTenant || tenantName}
- // Branding's cover note wins; a report's own wording is the fallback.
- footerNote={theme.coverFooterText || coverFooterNote}
+ footerNote={coverNote}
>
{coverMeta}
diff --git a/src/components/CippPdf/SharingReportButton.jsx b/src/components/CippPdf/SharingReportButton.jsx
index 3ff51601b3cf..136a515f0c93 100644
--- a/src/components/CippPdf/SharingReportButton.jsx
+++ b/src/components/CippPdf/SharingReportButton.jsx
@@ -211,7 +211,7 @@ export const SharingReportDocument = ({
/>
>
) : (
-
+
No anonymous link grants write access.
)}
@@ -246,7 +246,7 @@ export const SharingReportDocument = ({
/>
>
) : (
-
+
Every anonymous link has an expiry date set.
)}
@@ -284,7 +284,7 @@ export const SharingReportDocument = ({
/>
>
) : (
-
+
External and anonymous shares point at individual files rather than folders.
)}
@@ -316,7 +316,7 @@ export const SharingReportDocument = ({
/>
>
) : (
-
+
Nothing has been shared with an identity outside the organisation.
)}
diff --git a/src/components/CippPdf/index.js b/src/components/CippPdf/index.js
index 71468d969335..fbd7dba3192f 100644
--- a/src/components/CippPdf/index.js
+++ b/src/components/CippPdf/index.js
@@ -14,6 +14,10 @@ export {
REPORT_COLOURS,
REPORT_SERIES_SEMANTIC,
applyReportVariables,
+ applyFooterText,
+ applyWatermarkText,
+ FOOTER_MAX_LENGTH,
+ WATERMARK_MAX_LENGTH,
REPORT_COLOUR_ROLES,
asReportTheme,
buildPalette,
diff --git a/src/components/CippPdf/previewSampleData.js b/src/components/CippPdf/previewSampleData.js
index 146556bba968..f3a607f5088d 100644
--- a/src/components/CippPdf/previewSampleData.js
+++ b/src/components/CippPdf/previewSampleData.js
@@ -350,22 +350,204 @@ export const SAMPLE_SHADOW_AI = {
],
}
-/** BEC remediation report. */
+/** BEC remediation report. Field shapes mirror the real Push-BECRun payload so the preview
+ * renders every report section with plausible values rather than 'Unknown' placeholders. */
export const SAMPLE_BEC = {
userData: { displayName: 'Sample User', userPrincipalName: 'sample.user@example.com' },
becData: {
ExtractedAt: '2026-08-05T09:00:00Z',
- ExtractResult: 'Completed',
- NewRules: [{ Name: 'Sample forwarding rule', MoveToFolder: 'RSS Feeds' }],
- InboxRuleChanges: [{ Name: 'Sample rule change' }],
- NewUsers: [],
- AddedApps: [{ DisplayName: 'Sample OAuth app' }],
- MailboxPermissionChanges: [{ Grantee: 'sample.other@example.com' }],
- MFADevices: [{ Device: 'Sample phone' }],
- ChangedPasswords: [{ User: 'sample.user@example.com' }],
- TrustedSenders: [],
- BlockedSenders: [],
- SafelistChanges: [],
+ ExtractResult: 'Successfully extracted logs from auditlog',
+ AnalysisWindowDays: 7,
+ NewRules: [
+ {
+ Name: 'Sample forwarding rule',
+ Description: 'Move messages from billing@example.com to folder RSS Feeds',
+ MoveToFolder: 'RSS Feeds',
+ RecentlyChanged: true,
+ },
+ ],
+ InboxRuleChanges: [
+ {
+ Operation: 'New-InboxRule',
+ UserKey: 'sample.user@example.com',
+ RuleName: 'Sample forwarding rule',
+ Parameters: 'MoveToFolder=RSS Feeds; MarkAsRead=True',
+ Date: '2026-08-03T11:24:00Z',
+ ClientIP: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ },
+ ],
+ NewUsers: [
+ {
+ displayName: 'Sample Contractor',
+ userPrincipalName: 'sample.contractor@example.com',
+ createdDateTime: '2026-08-02T08:00:00Z',
+ },
+ ],
+ AddedApps: [
+ {
+ displayName: 'Sample OAuth app',
+ appId: '00000000-0000-0000-0000-000000000001',
+ publisher: 'Sample Publisher',
+ createdDateTime: '2026-08-01T10:00:00Z',
+ MaliciousMatch: null,
+ },
+ ],
+ MaliciousSPs: [
+ {
+ displayName: 'Sample Mail Sync Tool',
+ appId: '00000000-0000-0000-0000-000000000002',
+ accountEnabled: true,
+ createdDateTime: '2026-07-30T09:30:00Z',
+ CatalogName: 'Sample Mail Sync Tool',
+ Categories: ['Mailbox exfiltration', 'Business Email Compromise'],
+ Description: 'Sample catalog entry used for preview data.',
+ },
+ ],
+ MailboxPermissionChanges: [
+ {
+ Operation: 'Add-MailboxPermission',
+ UserKey: 'admin@example.com',
+ ObjectId: 'sample.user@example.com',
+ Permissions: 'FullAccess',
+ TargetsSuspect: true,
+ },
+ ],
+ SentMessages: [
+ {
+ MessageTraceId: '00000000-0000-0000-0000-000000000003',
+ Status: 'Delivered',
+ Subject: 'Sample invoice',
+ RecipientAddress: 'supplier@example.net',
+ Received: '2026-08-04 15:02:11Z',
+ FromIP: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ },
+ ],
+ SentMessageAnalysis: {
+ TotalMessages: 47,
+ TotalRecipients: 212,
+ RepeatedSubjects: [
+ {
+ Subject: 'Sample invoice',
+ MessageCount: 38,
+ RecipientCount: 190,
+ FirstSent: '2026-08-04 14:55:00Z',
+ LastSent: '2026-08-04 15:20:00Z',
+ Flagged: true,
+ },
+ ],
+ FlaggedSubjectCount: 1,
+ Bursts: [
+ {
+ WindowStart: '2026-08-04 15:00:00Z',
+ WindowMinutes: 10,
+ MessageCount: 31,
+ RecipientCount: 160,
+ TopSubject: 'Sample invoice',
+ },
+ ],
+ Flagged: true,
+ },
+ MFADevices: [
+ {
+ '@odata.type': '#microsoft.graph.microsoftAuthenticatorAuthenticationMethod',
+ displayName: 'Sample phone',
+ createdDateTime: '2026-08-03T12:00:00Z',
+ },
+ ],
+ ChangedPasswords: [
+ {
+ displayName: 'Sample User',
+ userPrincipalName: 'sample.user@example.com',
+ lastPasswordChangeDateTime: '2026-08-03T12:05:00Z',
+ },
+ ],
+ TrustedSenders: ['trusted@example.net', 'example-partner.com'],
+ BlockedSenders: ['security-alerts@example.org'],
+ SafelistChanges: [
+ {
+ Operation: 'Set-MailboxJunkEmailConfiguration',
+ UserKey: 'sample.user@example.com',
+ Date: '2026-08-03T11:30:00Z',
+ ClientIP: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ Trusted: ['attacker-domain.example'],
+ Blocked: null,
+ },
+ ],
+ SharingChanges: [
+ {
+ Operation: 'AnonymousLinkCreated',
+ UserKey: 'sample.user@example.com',
+ Date: '2026-08-04T10:15:00Z',
+ Workload: 'OneDrive',
+ FileName: 'Payroll Q3.xlsx',
+ ItemUrl: 'https://example-my.sharepoint.com/personal/sample_user/Documents/Payroll Q3.xlsx',
+ Target: null,
+ TargetType: null,
+ ClientIP: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ },
+ ],
+ IntuneDevices: [
+ {
+ id: '00000000-0000-0000-0000-000000000004',
+ deviceName: 'SAMPLE-VM01',
+ operatingSystem: 'Windows',
+ osVersion: '10.0.26100',
+ complianceState: 'noncompliant',
+ enrolledDateTime: '2026-08-03T13:00:00Z',
+ lastSyncDateTime: '2026-08-05T08:00:00Z',
+ deviceEnrollmentType: 'windowsAzureADJoin',
+ serialNumber: 'SAMPLE1234',
+ },
+ ],
+ SuspectUserSignIns: [
+ {
+ CreatedDateTime: '2026-08-04T22:14:00Z',
+ AppDisplayName: 'Office 365 Exchange Online',
+ ClientAppUsed: 'Browser',
+ Status: 'Success',
+ IPAddress: '203.0.113.10',
+ Country: 'NG',
+ City: 'Lagos',
+ ForeignLocation: true,
+ },
+ {
+ CreatedDateTime: '2026-08-04T09:02:00Z',
+ AppDisplayName: 'Microsoft Teams',
+ ClientAppUsed: 'Mobile Apps and Desktop clients',
+ Status: 'Success',
+ IPAddress: '198.51.100.24',
+ Country: 'US',
+ City: 'Seattle',
+ ForeignLocation: false,
+ },
+ ],
+ LocationAnalysis: {
+ UsageLocation: 'US',
+ UserRegisteredCountry: 'United States',
+ SignInCountries: [
+ { Country: 'US', Count: 41 },
+ { Country: 'NG', Count: 9 },
+ ],
+ ForeignSignInCount: 9,
+ ForeignSuccessfulSignInCount: 8,
+ ForeignRuleChangeCount: 1,
+ ForeignSafelistChangeCount: 1,
+ ForeignSharingChangeCount: 1,
+ ForeignSentMessageCount: 1,
+ Note: null,
+ },
},
}
diff --git a/src/components/CippPdf/reportPdfPrimitives.jsx b/src/components/CippPdf/reportPdfPrimitives.jsx
index c671983dba19..94049897534a 100644
--- a/src/components/CippPdf/reportPdfPrimitives.jsx
+++ b/src/components/CippPdf/reportPdfPrimitives.jsx
@@ -1,6 +1,6 @@
import { Children } from 'react'
import { Text, View, Image, Page } from '@react-pdf/renderer'
-import { REPORT_COLOURS, applyReportVariables } from './reportTheme'
+import { REPORT_COLOURS, applyFooterText, applyWatermarkText } from './reportTheme'
import { useReport, useReportStyles } from './reportContext'
import { DEFAULT_PAGE_SETUP, TABLE_ROW_PADDING, contentWidth } from './reportPdfStyles'
import { wrapLongTokens } from './measureText'
@@ -124,7 +124,7 @@ export const ContentPage = ({ title, subtitle, children, ...props }) => {
*/
export const PageFooter = ({ styles, label, theme, variables }) => {
const templated = theme?.footer?.enabled
- ? applyReportVariables(theme.footer.template, variables)
+ ? applyFooterText(theme.footer.template, variables)
: ''
// Configured branding wins over the report's own label. The reverse — which this did at first —
// meant every report that passed a label silently ignored the footer text an MSP had set, which
@@ -178,8 +178,16 @@ export const ReportPage = ({
)
-export const Watermark = ({ styles, theme, text, onDark = false }) => {
- const value = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '')
+/**
+ * Diagonal mark drawn over every page. Same `%variable%` substitution as the footer — branding
+ * stores a template (e.g. `%tenantname%`), and the report fills it from the surrounding context.
+ * The 40-character ceiling is applied to the *resolved* string, after variables expand.
+ */
+export const Watermark = ({ styles, theme, text, variables: variablesProp, onDark = false }) => {
+ const report = useReport()
+ const variables = variablesProp ?? report.variables
+ const template = text ?? (theme?.watermark?.enabled ? theme.watermark.text : '')
+ const value = template ? applyWatermarkText(template, variables) : ''
if (!value) return null
return (
diff --git a/src/components/CippPdf/reportTheme.js b/src/components/CippPdf/reportTheme.js
index 6e288ae766c7..c2443b55d26a 100644
--- a/src/components/CippPdf/reportTheme.js
+++ b/src/components/CippPdf/reportTheme.js
@@ -169,6 +169,12 @@ const buildSeries = (primary, secondary) => {
const DEFAULT_FOOTER_TEMPLATE = ''
const DEFAULT_WATERMARK_TEXT = ''
+/** Hard ceiling for page/cover footer text — applied after `%variable%` substitution. */
+export const FOOTER_MAX_LENGTH = 200
+
+/** Hard ceiling for the mark drawn on the page — applied after `%variable%` substitution. */
+export const WATERMARK_MAX_LENGTH = 40
+
/**
* The parts of a report that can be coloured independently.
*
@@ -333,6 +339,23 @@ export const applyReportVariables = (template, variables = {}) => {
})
}
+/**
+ * Resolve a watermark template and enforce the on-page length ceiling.
+ *
+ * The branding field stores a template (and rejects templates over the same limit). Tenant names
+ * and other variables can still expand past it at render time — that is when the ceiling is
+ * applied, so a long `%tenantname%` cannot spill a mark across the whole page.
+ */
+export const applyWatermarkText = (template, variables = {}) =>
+ applyReportVariables(template, variables).slice(0, WATERMARK_MAX_LENGTH)
+
+/**
+ * Resolve page-footer / cover-note text and enforce the length ceiling after substitution.
+ * Same reason as the watermark: a long `%tenantname%` must not blow past the stored limit.
+ */
+export const applyFooterText = (template, variables = {}) =>
+ applyReportVariables(template, variables).slice(0, FOOTER_MAX_LENGTH)
+
/**
* Build the theme a report renders against.
*
diff --git a/src/components/CippSettings/CippBrandingCoverPreview.jsx b/src/components/CippSettings/CippBrandingCoverPreview.jsx
index a4c016ad2b14..3dc4b86c4a8f 100644
--- a/src/components/CippSettings/CippBrandingCoverPreview.jsx
+++ b/src/components/CippSettings/CippBrandingCoverPreview.jsx
@@ -1,7 +1,7 @@
import { Box, Typography } from "@mui/material";
import { resolveCoverImage } from "../CippPdf/resolveCoverImage";
import { createReportStyles } from "../CippPdf/reportPdfStyles";
-import { createReportTheme } from "../CippPdf/reportTheme";
+import { applyFooterText, applyWatermarkText, createReportTheme } from "../CippPdf/reportTheme";
import {
SAMPLE_BEC,
SAMPLE_PERMISSIONS,
@@ -37,6 +37,8 @@ export const REPORT_COVER_PRESETS = [
{
id: "executive",
label: "Executive Report",
+ // Must match `reportName` on ExecutiveReportDocument — cover-mock `%reportname%` uses this.
+ reportName: "Executive Summary",
coverLabel: "Security Assessment",
title: "Executive",
accent: "Summary",
@@ -48,6 +50,7 @@ export const REPORT_COVER_PRESETS = [
{
id: "shadowAI",
label: "Shadow AI Report",
+ reportName: "Shadow AI Report",
coverLabel: "AI Risk Assessment",
title: "Shadow AI",
accent: "Report",
@@ -60,6 +63,7 @@ export const REPORT_COVER_PRESETS = [
{
id: "bec",
label: "BEC Remediation",
+ reportName: "BEC Analysis Report",
coverLabel: "Security Incident Report",
title: "BEC Compromise",
accent: "Analysis",
@@ -74,6 +78,7 @@ export const REPORT_COVER_PRESETS = [
{
id: "sharing",
label: "Sharing Report",
+ reportName: "Sharing Report",
coverLabel: "Data Sharing Review",
title: "Sharing",
accent: "Report",
@@ -88,6 +93,7 @@ export const REPORT_COVER_PRESETS = [
{
id: "permissions",
label: "Permissions Report",
+ reportName: "Permissions Report",
coverLabel: "Access Review",
title: "Permissions",
accent: "Report",
@@ -102,6 +108,8 @@ export const REPORT_COVER_PRESETS = [
// the report builder, so it belongs after the reports that are the same every time.
id: "reportBuilder",
label: "Report Builder",
+ // Matches the sample template name used by CippBrandingReportPreview for this report type.
+ reportName: "Quarterly Security Review",
coverLabel: "Assessment Report",
title: "Custom",
accent: "Report",
@@ -152,6 +160,18 @@ const CippBrandingCoverPreview = ({
month: "long",
day: "numeric",
});
+ // Same substitution + length ceiling the PDF applies — without it, typing %tenantname% in
+ // branding shows the token literally in this mock while real reports resolve it.
+ const previewVariables = {
+ tenantname: SAMPLE_TENANT_NAME,
+ reportname: preset.reportName,
+ reportdate: currentDate,
+ };
+ const watermarkLabel = applyWatermarkText(theme.watermark.text, previewVariables);
+ const coverFooterLabel = applyFooterText(
+ theme.coverFooterText || preset.footer,
+ previewVariables
+ );
return (
- {theme.watermark.text}
+ {watermarkLabel}
)}
@@ -339,7 +359,7 @@ const CippBrandingCoverPreview = ({
}}
>
{/* A configured cover note replaces the report's own wording, exactly as the PDF does. */}
- {theme.coverFooterText || preset.footer}
+ {coverFooterLabel}
diff --git a/src/components/CippSettings/CippBrandingSettings.jsx b/src/components/CippSettings/CippBrandingSettings.jsx
index c798c9afbcf0..6a01076a5a58 100644
--- a/src/components/CippSettings/CippBrandingSettings.jsx
+++ b/src/components/CippSettings/CippBrandingSettings.jsx
@@ -33,7 +33,7 @@ import {
normalizeLogoImageIds,
normalizeLogoUploads,
} from "../CippPdf/resolveCoverImage";
-import { REPORT_COLOUR_ROLES } from "../CippPdf/reportTheme";
+import { FOOTER_MAX_LENGTH, REPORT_COLOUR_ROLES, WATERMARK_MAX_LENGTH } from "../CippPdf/reportTheme";
import { BRANDING_GALLERY_QUERY_KEY } from "../CippPdf/useBrandingSettings";
import { useForm } from "react-hook-form";
@@ -78,7 +78,7 @@ const FOOTER_TOOLTIP =
"Text shown at the bottom of every report page. Type % for CIPP's variables, plus %reportname% and %reportdate% which reports add. Report templates can override this or switch it off individually.";
const WATERMARK_TOOLTIP =
- "Diagonal text drawn faintly across every page of a report, cover included — e.g. DRAFT or CONFIDENTIAL. Typing text is enough to show it; the toggle only exists to switch it off without losing the wording.";
+ "Diagonal text drawn faintly across every page of a report, cover included. Type % for CIPP's variables (e.g. %tenantname%), or a static mark such as DRAFT. Typing text is enough to show it; the toggle only exists to switch it off without losing the wording.";
const REPORT_DEFAULTS_TOOLTIP =
"Which preset each report reaches for when nothing else says otherwise. A report template with its own preset still wins over this, and this still wins over the default branding above.";
@@ -1412,12 +1412,12 @@ const CippBrandingSettings = () => {
name="footerText"
formControl={formControl}
placeholder="%tenantname% — prepared by Contoso IT — %reportdate%"
- helperText="Type % for variables. Reports add %reportname% and %reportdate%."
+ helperText={`Type % for variables. Reports add %reportname% and %reportdate%. After substitution, text is capped at ${FOOTER_MAX_LENGTH} characters.`}
includeSystemVariables={true}
validators={{
maxLength: {
- value: 200,
- message: "Footer text must be 200 characters or fewer",
+ value: FOOTER_MAX_LENGTH,
+ message: `Footer text must be ${FOOTER_MAX_LENGTH} characters or fewer`,
},
}}
/>
@@ -1426,13 +1426,13 @@ const CippBrandingSettings = () => {
name="coverFooterText"
label="Cover Note"
placeholder="Blank = each report's own wording"
- helperText="Replaces the confidentiality note on cover pages"
+ helperText={`Replaces the confidentiality note on cover pages. After substitution, text is capped at ${FOOTER_MAX_LENGTH} characters.`}
includeSystemVariables={true}
formControl={formControl}
validators={{
maxLength: {
- value: 200,
- message: "Cover note must be 200 characters or fewer",
+ value: FOOTER_MAX_LENGTH,
+ message: `Cover note must be ${FOOTER_MAX_LENGTH} characters or fewer`,
},
}}
/>
@@ -1462,14 +1462,16 @@ const CippBrandingSettings = () => {
diff --git a/src/components/CippTable/CippQueueTracker.js b/src/components/CippTable/CippQueueTracker.js
index 20a4fd6d62cd..ac35062aa3c3 100644
--- a/src/components/CippTable/CippQueueTracker.js
+++ b/src/components/CippTable/CippQueueTracker.js
@@ -34,9 +34,10 @@ export const CippQueueTracker = ({ queueId, queryKey, title, onQueueComplete })
data: { QueueId: effectiveQueueId },
queryKey: `CippQueue-${effectiveQueueId || "unknown"}`,
waiting: shouldShowQueue && !!effectiveQueueId && !isQueueCompleted,
- refetchInterval: (data) => {
- // Check if the current data shows completion
- const currentData = data?.[0];
+ refetchInterval: (query) => {
+ // TanStack Query v5 hands this callback the Query object, not the data - the response
+ // has to be read off query.state or the completion check below never matches.
+ const currentData = query?.state?.data?.[0];
const isCurrentCompleted =
currentData?.Status === "Completed" ||
currentData?.Status === "Failed" ||
diff --git a/src/data/M365Licenses.json b/src/data/M365Licenses.json
index a5075fa9e6ef..61bd872fbf3b 100644
--- a/src/data/M365Licenses.json
+++ b/src/data/M365Licenses.json
@@ -38287,6 +38287,14 @@
"Service_Plan_Id": "65cc641f-cccd-4643-97e0-a17e3045e541",
"Service_Plans_Included_Friendly_Names": "Microsoft Records Management"
},
+ {
+ "Product_Display_Name": "Office 365 E5",
+ "String_Id": "ENTERPRISEPREMIUM",
+ "GUID": "c7df2760-2c81-4ef7-b578-5b5392b571df",
+ "Service_Plan_Name": "MICROSOFT_TEAMS_EVENTS",
+ "Service_Plan_Id": "29c62f1c-8ffc-4304-9cb9-398a6aa1852b",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Events"
+ },
{
"Product_Display_Name": "Office 365 E5 EEA (no Teams)",
"String_Id": "Office_365_w/o_Teams_Bundle_E5",
@@ -47918,5 +47926,61 @@
"Service_Plan_Name": "INSIDER_RISK_MANAGEMENT_FOR_AGENTS",
"Service_Plan_Id": "004ddfc0-c92f-4b0a-90c5-c60646299d71",
"Service_Plans_Included_Friendly_Names": "Microsoft Purview Insider Risk Management for Agents"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "TEAMSPRO_MGMT",
+ "Service_Plan_Id": "0504111f-feb8-4a3c-992a-70280f9a2869",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Intelligent"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "TEAMSPRO_CUST",
+ "Service_Plan_Id": "cc8c0802-a325-43df-8cba-995d0c6cb373",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Personalized"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "TEAMSPRO_PROTECTION",
+ "Service_Plan_Id": "f8b44f54-18bb-46a3-9658-44ab58712968",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Secure"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "TEAMSPRO_VIRTUALAPPT",
+ "Service_Plan_Id": "9104f592-f2a7-4f77-904c-ca5a5715883f",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointment"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "MCO_VIRTUAL_APPT",
+ "Service_Plan_Id": "711413d0-b36e-4cd4-93db-0a50a4ab7ea3",
+ "Service_Plans_Included_Friendly_Names": "Microsoft Teams Premium Virtual Appointments"
+ },
+ {
+ "Product_Display_Name": "Microsoft Teams Premium",
+ "String_Id": "M365_TEAMS_PREMIUM",
+ "GUID": "6432c818-bcef-43b6-9290-aec052964950",
+ "Service_Plan_Name": "QUEUES_APP",
+ "Service_Plan_Id": "ab2d4fb5-f80a-4bf1-a11d-7f1da254041b",
+ "Service_Plans_Included_Friendly_Names": "Queues app for Microsoft Teams"
+ },
+ {
+ "Product_Display_Name": "Skype for Business PSTN Domestic and International Calling",
+ "String_Id": "MCOSMS2",
+ "GUID": "d4009785-b899-4cab-97b6-d06a7c799507",
+ "Service_Plan_Name": "MCOSMS2",
+ "Service_Plan_Id": "d4009785-b899-4cab-97b6-d06a7c799507",
+ "Service_Plans_Included_Friendly_Names": "DOMESTIC AND INTERNATIONAL CALLING PLAN"
}
]
diff --git a/src/data/standards.json b/src/data/standards.json
index 560a9eceebe0..62a8932f623d 100644
--- a/src/data/standards.json
+++ b/src/data/standards.json
@@ -4,7 +4,7 @@
"cat": "Copilot (M365) Standards",
"tag": [],
"helpText": "Configures Microsoft 365 Copilot tenant policy settings: Copilot Chat pinning, blocking Copilot access to open content, Designer image generation, web search, and admin-center Copilot. Each setting can be left unconfigured, enabled, or disabled. These settings are managed through the Copilot policy service (Cloud Policy / Intune) and are applied at the tenant level.",
- "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. The exact accepted value per setting is a string (commonly \"1\"/\"0\") and should be validated against a Copilot-licensed tenant.",
+ "docsDescription": "Manages Microsoft 365 Copilot admin policy settings via the `/copilot/admin/policySettings` Microsoft Graph API (beta). Each of the five supported settings can be independently set or left unmanaged using the \"Do not configure\" option. NOTE: this API currently requires delegated authentication and supports only tenant-level policies; settings scoped to group-level policies return an error and are skipped. Values are strings whose meaning is per-setting, not uniform: web search is three-state (\"0\" enabled everywhere, \"1\" disabled everywhere, \"2\" disabled in Copilot Work mode only) and Designer image generation is inverted (\"1\" disables it, \"0\" enables it). Graph treats these as opaque strings and validates nothing, so do not assume 1=on/0=off for a setting you have not verified against a Copilot-licensed tenant.",
"executiveText": "Provides centralized governance of Microsoft 365 Copilot capabilities across the organization. Administrators can control whether Copilot Chat is pinned for users, whether Copilot can access open files, and whether features such as image generation and web search are available, helping balance employee productivity with data governance and compliance requirements.",
"addedComponent": [
{
@@ -51,11 +51,11 @@
"name": "standards.CopilotSettings.allowWebSearch",
"options": [
{ "label": "Do not configure", "value": "donotconfigure" },
- { "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "2" },
+ { "label": "Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "0" },
{ "label": "Disabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat", "value": "1" },
{
"label": "Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat",
- "value": "0"
+ "value": "2"
}
]
},
@@ -7597,7 +7597,7 @@
"impact": "High Impact",
"impactColour": "danger",
"addedDate": "2026-04-28",
- "powershellEquivalent": "Set-SPOTenant -CustomScriptsRestrictMode $true",
+ "powershellEquivalent": "Portal only",
"recommendedBy": ["CIPP"],
"requiredCapabilities": [
"SHAREPOINTWAC",
diff --git a/src/pages/copilot/settings/index.js b/src/pages/copilot/settings/index.js
index 0f8c018b2e99..67cf5c246515 100644
--- a/src/pages/copilot/settings/index.js
+++ b/src/pages/copilot/settings/index.js
@@ -14,7 +14,11 @@ const Page = () => {
url: '/api/ExecCopilotSettings',
icon: ,
data: { settingId: 'settingId' },
- condition: (row) => row.settingId !== 'microsoft.copilot.allowwebsearch',
+ condition: (row) =>
+ ![
+ 'microsoft.copilot.allowwebsearch',
+ 'microsoft.copilot.imagegeneration',
+ ].includes(row.settingId),
fields: [
{
type: 'autoComplete',
@@ -32,6 +36,31 @@ const Page = () => {
confirmText: "Set '[setting]' to the selected state?",
relatedQueryKeys: [queryKey],
},
+ {
+ // Designer image generation inverts the usual toggle: '1' disables, '0' enables.
+ label: 'Set Status',
+ type: 'POST',
+ url: '/api/ExecCopilotSettings',
+ icon: ,
+ data: { settingId: 'settingId' },
+ condition: (row) => row.settingId === 'microsoft.copilot.imagegeneration',
+ fields: [
+ {
+ type: 'autoComplete',
+ name: 'value',
+ label: 'Desired state',
+ multiple: false,
+ creatable: false,
+ options: [
+ { label: 'Enabled', value: '0' },
+ { label: 'Disabled', value: '1' },
+ { label: 'Not configured', value: 'clear' },
+ ],
+ },
+ ],
+ confirmText: "Set '[setting]' to the selected state?",
+ relatedQueryKeys: [queryKey],
+ },
{
// Web search is a three-state policy; its values match the config.office.com options
label: 'Set Status',
@@ -51,7 +80,7 @@ const Page = () => {
{
label:
'Enabled in Microsoft 365 Copilot and Microsoft 365 Copilot Chat',
- value: '2',
+ value: '0',
},
{
label:
@@ -61,7 +90,7 @@ const Page = () => {
{
label:
'Disabled in Microsoft 365 Copilot Work mode, Enabled in Microsoft 365 Copilot Chat',
- value: '0',
+ value: '2',
},
{ label: 'Not configured', value: 'clear' },
],
diff --git a/src/pages/identity/administration/users/user/bec.jsx b/src/pages/identity/administration/users/user/bec.jsx
index d63a7fcac20f..faac9f2e1dc5 100644
--- a/src/pages/identity/administration/users/user/bec.jsx
+++ b/src/pages/identity/administration/users/user/bec.jsx
@@ -106,17 +106,26 @@ const Page = () => {
}
}
- if (becPollingCall.isSuccess && becPollingCall.data && !becPollingCall.data?.Waiting) {
+ // The !restart guard keeps a refresh from being cancelled: between clicking Refresh Data
+ // and the overwrite call resolving, the polling cache still holds the previous run, which
+ // would otherwise read as "done" and stop the loading state.
+ if (!restart && becPollingCall.isSuccess && becPollingCall.data && !becPollingCall.data?.Waiting) {
setIsLoading(false)
}
}, [becPollingCall.dataUpdatedAt, becInitialCall])
const restartProcess = () => {
setRestart(true)
- becPollingCall.refetch()
+ setIsLoading(true)
+ // The 500ms lets the re-render register Overwrite on the initial call's params. Poll only
+ // after the initial call resolves: the backend resets the cache row to Waiting before it
+ // responds, so a poll issued after that cannot race the reset and resurface the old run.
setTimeout(() => {
- becInitialCall.refetch()
- becPollingCall.refetch()
+ becInitialCall.refetch().finally(() => {
+ // one-shot: without this every later refetch would force a fresh run
+ setRestart(false)
+ becPollingCall.refetch()
+ })
}, 500)
}
@@ -150,21 +159,23 @@ const Page = () => {
const getUserMessage = () => {
if (!becPollingCall.data) return null
if (becPollingCall.data.NewUsers && becPollingCall.data.NewUsers.length > 0) {
- return 'New users have been found in the last 14 days. Please review the list below and take action as needed.'
+ return 'New users have been found in the last 7 days. Please review the list below and take action as needed.'
}
return 'No new users found.'
}
const getAppMessage = () => {
if (!becPollingCall.data) return null
+ const maliciousAddedCount = (becPollingCall.data.AddedApps || []).filter(
+ (app) => app?.MaliciousMatch
+ ).length
+ const maliciousPresentCount = becPollingCall.data.MaliciousSPs?.length || 0
+ if (maliciousAddedCount > 0 || maliciousPresentCount > 0) {
+ return `Potential Breach found: ${
+ maliciousAddedCount + maliciousPresentCount
+ } application(s) in this tenant match the CIPP known-malicious application catalog. Consent-based access survives a password reset, so remove these applications unless their presence is explained.`
+ }
if (becPollingCall.data.AddedApps && becPollingCall.data.AddedApps.length > 0) {
- // Example condition to check for potential breach
- const hasPotentialBreach = becPollingCall.data.AddedApps.some(
- (app) => /* your condition here */ false
- )
- if (hasPotentialBreach) {
- return 'Potential Breach found.'
- }
return 'New applications have been found. Please review the list below and take action as needed.'
}
return 'No new applications found.'
@@ -172,11 +183,13 @@ const Page = () => {
const getMailboxPermissionMessage = () => {
if (!becPollingCall.data) return null
- if (
- becPollingCall.data.MailboxPermissionChanges &&
- becPollingCall.data.MailboxPermissionChanges.length > 0
- ) {
- return 'Mailbox permission changes have been found.'
+ const changes = becPollingCall.data.MailboxPermissionChanges || []
+ if (changes.length > 0) {
+ const targeting = changes.filter((c) => c?.TargetsSuspect === true).length
+ if (targeting > 0) {
+ return `${changes.length} mailbox permission change(s) found across the tenant in the last 7 days, ${targeting} of which target this mailbox. Review those first.`
+ }
+ return `${changes.length} mailbox permission change(s) found across the tenant in the last 7 days. None appear to target this mailbox, but verify the list below.`
}
return 'No mailbox permission changes found.'
}
@@ -184,13 +197,38 @@ const Page = () => {
const getSentMessagesMessage = () => {
if (!becPollingCall.data) return null
if (becPollingCall.data.SentMessages && becPollingCall.data.SentMessages.length > 0) {
- return 'Sent messages have been found. Please review the list below for any suspicious activity.'
+ const analysis = becPollingCall.data.SentMessageAnalysis
+ const parts = [
+ `${analysis?.TotalMessages ?? becPollingCall.data.SentMessages.length} message(s) to ${
+ analysis?.TotalRecipients ?? becPollingCall.data.SentMessages.length
+ } recipient(s) were sent in the last 7 days`,
+ ]
+ if (analysis?.FlaggedSubjectCount > 0) {
+ parts.push(
+ `${analysis.FlaggedSubjectCount} subject(s) were sent as many separate messages or to many recipients — identical-subject mass mail is a classic sign of a compromised mailbox running a campaign`
+ )
+ }
+ if (analysis?.Bursts?.length > 0) {
+ parts.push(
+ `${analysis.Bursts.length} short burst(s) of high-volume sending were detected`
+ )
+ }
+ const foreignCount = becPollingCall.data.LocationAnalysis?.ForeignSentMessageCount || 0
+ if (foreignCount > 0) {
+ parts.push(
+ `${foreignCount} message(s) were sent from an IP outside the user's assigned usage location`
+ )
+ }
+ return `${parts.join('. ')}. Please review the list below for any suspicious activity.`
}
return 'No sent messages found in the specified time range.'
}
const getSafelistMessage = () => {
if (!becPollingCall.data) return null
+ if (becPollingCall.data.SafelistError) {
+ return `${becPollingCall.data.SafelistError} An empty list here is not proof the mailbox has none — refresh after fixing the underlying problem.`
+ }
const trustedCount = becPollingCall.data.TrustedSenders?.length || 0
const blockedCount = becPollingCall.data.BlockedSenders?.length || 0
const changeCount = becPollingCall.data.SafelistChanges?.length || 0
@@ -217,7 +255,9 @@ const Page = () => {
[becPollingCall.data]
)
- const intuneDevicesWindowStart = useMemo(() => {
+ // the analysis window: 7 days before the data was extracted. Shared by the Intune
+ // enrollment and MFA registration recency checks.
+ const analysisWindowStart = useMemo(() => {
const extractedAt = becPollingCall.data?.ExtractedAt
? new Date(becPollingCall.data.ExtractedAt)
: new Date()
@@ -227,6 +267,29 @@ const Page = () => {
return new Date(extractedAt.getTime() - 7 * 24 * 60 * 60 * 1000)
}, [becPollingCall.data?.ExtractedAt])
+ const recentMfaDeviceCount = useMemo(
+ () =>
+ (becPollingCall.data?.MFADevices || []).filter((method) => {
+ if (!method?.createdDateTime) return false
+ const created = new Date(method.createdDateTime)
+ if (Number.isNaN(created.getTime())) return false
+ return created >= analysisWindowStart
+ }).length,
+ [becPollingCall.data?.MFADevices, analysisWindowStart]
+ )
+
+ const foreignActivityCount = useMemo(() => {
+ const analysis = becPollingCall.data?.LocationAnalysis
+ if (!analysis) return 0
+ return (
+ (analysis.ForeignSignInCount || 0) +
+ (analysis.ForeignRuleChangeCount || 0) +
+ (analysis.ForeignSafelistChangeCount || 0) +
+ (analysis.ForeignSharingChangeCount || 0) +
+ (analysis.ForeignSentMessageCount || 0)
+ )
+ }, [becPollingCall.data?.LocationAnalysis])
+
const intuneDevices = useMemo(() => {
const devices = [...(becPollingCall.data?.IntuneDevices || [])]
devices.sort((a, b) => {
@@ -243,9 +306,9 @@ const Page = () => {
if (!device?.enrolledDateTime) return false
const enrolled = new Date(device.enrolledDateTime)
if (Number.isNaN(enrolled.getTime())) return false
- return enrolled >= intuneDevicesWindowStart
+ return enrolled >= analysisWindowStart
}).length,
- [intuneDevices, intuneDevicesWindowStart]
+ [intuneDevices, analysisWindowStart]
)
const intuneDeviceActions = useMemo(
@@ -253,6 +316,91 @@ const Page = () => {
[userSettingsDefaults.currentTenant]
)
+ const getMfaMessage = () => {
+ if (!becPollingCall.data) return null
+ const count = becPollingCall.data.MFADevices?.length || 0
+ if (count === 0) {
+ return 'No MFA methods are registered for this user. If MFA was expected, an attacker may have removed it; either way the account currently has no second factor.'
+ }
+ if (recentMfaDeviceCount > 0) {
+ return `${count} MFA method(s) registered, ${recentMfaDeviceCount} in the last 7 days. Verify the recent registrations were made by the user — attackers register their own method to keep access after a password reset.`
+ }
+ return `${count} MFA method(s) registered. Please review the list below and take action as required.`
+ }
+
+ const getSignInLocationMessage = () => {
+ if (!becPollingCall.data) return null
+ if (becPollingCall.data.SuspectUserSignInsError) {
+ return `${becPollingCall.data.SuspectUserSignInsError} This is not proof the user has no sign-ins — fix the underlying permission or licensing problem and refresh.`
+ }
+ const analysis = becPollingCall.data.LocationAnalysis
+ const signInCount = becPollingCall.data.SuspectUserSignIns?.length || 0
+ if (signInCount === 0) {
+ return 'No sign-ins were found for this user in the sign-in logs.'
+ }
+ const countries = (analysis?.SignInCountries || [])
+ .map((c) => `${c.Country} (${c.Count})`)
+ .join(', ')
+ if (!analysis?.UsageLocation) {
+ return `${
+ analysis?.Note ||
+ 'The user has no usage location assigned in Entra ID, so activity cannot be compared against an expected country.'
+ } Sign-in countries seen: ${countries || 'none recorded'}.`
+ }
+ const foreignParts = []
+ if (analysis.ForeignSignInCount > 0) {
+ foreignParts.push(
+ `${analysis.ForeignSignInCount} sign-in(s), of which ${
+ analysis.ForeignSuccessfulSignInCount || 0
+ } succeeded (failed foreign attempts are mostly password-spray noise)`
+ )
+ }
+ if (analysis.ForeignRuleChangeCount > 0) {
+ foreignParts.push(`${analysis.ForeignRuleChangeCount} inbox rule change(s)`)
+ }
+ if (analysis.ForeignSafelistChangeCount > 0) {
+ foreignParts.push(`${analysis.ForeignSafelistChangeCount} safelist change(s)`)
+ }
+ if (analysis.ForeignSharingChangeCount > 0) {
+ foreignParts.push(`${analysis.ForeignSharingChangeCount} sharing change(s)`)
+ }
+ if (analysis.ForeignSentMessageCount > 0) {
+ foreignParts.push(`${analysis.ForeignSentMessageCount} sent message(s)`)
+ }
+ if (foreignParts.length > 0) {
+ return `The user's assigned usage location is ${
+ analysis.UsageLocation
+ }, but activity originated outside it: ${foreignParts.join(
+ ', '
+ )}. Sign-in countries seen: ${countries}. Review the sign-ins below and the flagged rows in the checks above.`
+ }
+ return `All located activity matches the user's assigned usage location (${
+ analysis.UsageLocation
+ }). Sign-in countries seen: ${countries || 'none recorded'}.`
+ }
+
+ const getSharingMessage = () => {
+ if (!becPollingCall.data) return null
+ const changes = becPollingCall.data.SharingChanges || []
+ if (changes.length === 0) {
+ return 'No sharing links were created or changed by this account in the last 7 days.'
+ }
+ const anonymousCount = changes.filter((c) => c?.Operation?.startsWith('AnonymousLink')).length
+ const foreignCount = becPollingCall.data.LocationAnalysis?.ForeignSharingChangeCount || 0
+ const parts = [
+ `${changes.length} OneDrive/SharePoint sharing change(s) found in the last 7 days`,
+ ]
+ if (anonymousCount > 0) {
+ parts.push(`${anonymousCount} involve anonymous links, which anyone with the URL can open`)
+ }
+ if (foreignCount > 0) {
+ parts.push(`${foreignCount} were made from outside the user's usage location`)
+ }
+ return `${parts.join(
+ '. '
+ )}. Attackers share folders to keep pulling data after a password reset — review each link and remove any that are not explained.`
+ }
+
const getIntuneDevicesMessage = () => {
if (!becPollingCall.data) return null
if (becPollingCall.data.IntuneDevicesError) {
@@ -425,10 +573,16 @@ const Page = () => {
))}
@@ -465,7 +619,10 @@ const Page = () => {
{/* Check 3: New Applications */}
{getAppMessage()}
@@ -473,17 +630,48 @@ const Page = () => {
{becPollingCall.data?.AddedApps?.length > 0 && (
- {becPollingCall.data.AddedApps.map((app, index) => (
-
- ))}
+ {[...becPollingCall.data.AddedApps]
+ .sort((a, b) => !!b?.MaliciousMatch - !!a?.MaliciousMatch)
+ .map((app, index) => (
+
+ ))}
)}
+ {becPollingCall.data?.MaliciousSPs?.length > 0 && (
+
+
+ Known-malicious applications present in the tenant (any age)
+
+
+
+ {becPollingCall.data.MaliciousSPs.map((app, index) => (
+
+ ))}
+
+
+
+ )}
{/* Check 4: Mailbox permission changes */}
@@ -497,14 +685,20 @@ const Page = () => {
{becPollingCall.data?.MailboxPermissionChanges?.length > 0 && (
- {becPollingCall.data.MailboxPermissionChanges.map((permission, index) => (
-
- ))}
+ {[...becPollingCall.data.MailboxPermissionChanges]
+ .sort((a, b) => (b?.TargetsSuspect === true) - (a?.TargetsSuspect === true))
+ .map((permission, index) => (
+
+ ))}
)}
@@ -518,6 +712,52 @@ const Page = () => {
{getSentMessagesMessage()}
+ {becPollingCall.data?.SentMessageAnalysis?.RepeatedSubjects?.length > 0 && (
+
+
+ Repeated subjects
+
+
+
+ {becPollingCall.data.SentMessageAnalysis.RepeatedSubjects.map(
+ (group, index) => (
+
+ )
+ )}
+
+
+
+ )}
+ {becPollingCall.data?.SentMessageAnalysis?.Bursts?.length > 0 && (
+
+
+ Send bursts
+
+
+
+ {becPollingCall.data.SentMessageAnalysis.Bursts.map((burst, index) => (
+
+ ))}
+
+
+
+ )}
{becPollingCall.data?.SentMessages?.length > 0 && (
{
hideTitle={true}
title="Sent Messages"
data={becPollingCall.data.SentMessages}
- simpleColumns={['Subject', 'RecipientAddress', 'Status', 'Received', 'FromIP']}
+ simpleColumns={[
+ 'Subject',
+ 'RecipientAddress',
+ 'Status',
+ 'Received',
+ 'FromIP',
+ 'Country',
+ ]}
/>
)}
@@ -536,21 +783,34 @@ const Page = () => {
count={becPollingCall.data?.MFADevices?.length || 0}
>
- MFA Devices have been found. Please review the list below and take action as
- required
+ {getMfaMessage()}
{becPollingCall.data?.MFADevices?.length > 0 && (
- {becPollingCall.data.MFADevices.map((permission, index) => (
-
- ))}
+ {[...becPollingCall.data.MFADevices]
+ .sort(
+ (a, b) =>
+ new Date(b?.createdDateTime || 0) - new Date(a?.createdDateTime || 0)
+ )
+ .map((method, index) => {
+ const isRecent =
+ method?.createdDateTime &&
+ new Date(method.createdDateTime) >= analysisWindowStart
+ return (
+
+ )
+ })}
)}
@@ -584,12 +844,18 @@ const Page = () => {
-
+
{getSafelistMessage()}
{senderRows.length > 0 && (
@@ -614,8 +880,16 @@ const Page = () => {
@@ -662,6 +936,71 @@ const Page = () => {
)}
+ {/* Check 10: Sign-in Locations */}
+
+
+ {getSignInLocationMessage()}
+
+ {becPollingCall.data?.SuspectUserSignIns?.length > 0 && (
+
+
+
+ )}
+
+
+ {/* Check 11: Sharing Links */}
+
+
+ {getSharingMessage()}
+
+ {becPollingCall.data?.SharingChanges?.length > 0 && (
+
+
+
+ )}
+
+
{/* Report Data */}
diff --git a/src/pages/identity/administration/users/user/exchange.jsx b/src/pages/identity/administration/users/user/exchange.jsx
index a87124466131..f2ef68719bcd 100644
--- a/src/pages/identity/administration/users/user/exchange.jsx
+++ b/src/pages/identity/administration/users/user/exchange.jsx
@@ -801,26 +801,16 @@ const Page = () => {
icon: ,
url: '/api/ExecModifyCalPerms',
customDataformatter: (row, action, formData) => {
- var permissions = []
- if (Array.isArray(row)) {
- row.forEach((item) => {
- const originalUser = item._raw ? item._raw.User : item.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: item.AccessRights,
- FolderName: item.FolderName,
- Modification: 'Remove',
- })
- })
- } else {
- const originalUser = row._raw ? row._raw.User : row.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: row.AccessRights,
- FolderName: row.FolderName,
- Modification: 'Remove',
- })
- }
+ const rows = Array.isArray(row) ? row : [row]
+ // UserId is the resolved recipient; User is only a display
+ // name, which Exchange cannot resolve when two share it.
+ const permissions = rows.map((item) => ({
+ UserID: item._raw?.UserId || item._raw?.User || item.User,
+ DisplayName: item._raw?.User || item.User,
+ PermissionLevel: item.AccessRights,
+ FolderName: item.FolderName,
+ Modification: 'Remove',
+ }))
return {
userID: graphUserRequest.data?.[0]?.userPrincipalName,
tenantFilter: userSettingsDefaults.currentTenant,
@@ -870,7 +860,8 @@ const Page = () => {
tenantFilter: userSettingsDefaults.currentTenant,
permissions: [
{
- UserID: originalUser, // Use original identifier for API calls
+ UserID: data._raw?.UserId || originalUser,
+ DisplayName: originalUser,
PermissionLevel: data.AccessRights,
FolderName: data.FolderName,
Modification: 'Remove',
@@ -944,26 +935,16 @@ const Page = () => {
icon: ,
url: '/api/ExecModifyContactPerms',
customDataformatter: (row, action, formData) => {
- var permissions = []
- if (Array.isArray(row)) {
- row.forEach((item) => {
- const originalUser = item._raw ? item._raw.User : item.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: item.AccessRights,
- FolderName: item.FolderName,
- Modification: 'Remove',
- })
- })
- } else {
- const originalUser = row._raw ? row._raw.User : row.User
- permissions.push({
- UserID: originalUser, // Use original identifier for API calls
- PermissionLevel: row.AccessRights,
- FolderName: row.FolderName,
- Modification: 'Remove',
- })
- }
+ const rows = Array.isArray(row) ? row : [row]
+ // UserId is the resolved recipient; User is only a display
+ // name, which Exchange cannot resolve when two share it.
+ const permissions = rows.map((item) => ({
+ UserID: item._raw?.UserId || item._raw?.User || item.User,
+ DisplayName: item._raw?.User || item.User,
+ PermissionLevel: item.AccessRights,
+ FolderName: item.FolderName,
+ Modification: 'Remove',
+ }))
return {
userID: graphUserRequest.data?.[0]?.userPrincipalName,
tenantFilter: userSettingsDefaults.currentTenant,
@@ -1013,7 +994,8 @@ const Page = () => {
tenantFilter: userSettingsDefaults.currentTenant,
permissions: [
{
- UserID: originalUser, // Use original identifier for API calls
+ UserID: data._raw?.UserId || originalUser,
+ DisplayName: originalUser,
PermissionLevel: data.AccessRights,
FolderName: data.FolderName,
Modification: 'Remove',