T1556.009 - Detect and prevent suspicious conditional access policy modifications
Introduction
In April 2024, MITRE came with their new V15 version of ATT&CK. In this version a new sub-technique was introduced called 'T1556.009 - Modify Authentication Process: Conditional Access Policies'. This was, in my opinion, a great addition to the framework, since it is an important technique which can be abused by adversaries. By changing a Conditional Access policy (later referred to as 'CA policy'), an adversary can establish Credential Access, Defense Evasion, and Persistence in Entra ID. Since it is such a vital component, I thought it was time to do a bit of a deep dive into how we can detect and mitigate suspicious CA policy changes.
But before we go into this, I want to set expectations right for this blog post. It is prudent for an IT professional to regularly perform due diligence and review changes on critical security mechanisms such as CA policies in Entra ID. Therefore it can be interesting to flag any change that happens to a CA policy. However, from a SOC perspective, it is not interesting to flag every change that happens to a policy. Imagine that a client is creating new policies and is adding new security controls to those policies. In this case, a SOC would get an alert for every change that the client is performing. This would result in alert fatigue, which we must limit at a managed detection and response service. Because of this I wanted to focus on the specific procedures attackers can use to change the authentication process in their favor, rather than flagging every change that might not always have malicious intent.
Why flagging suspicious policy changes
You might wonder why you would bother to flag suspicious CA policy changes. After all, only highly privileged users can edit them right? There are a couple of reasons why I recommend doing it.
1) The first reason I briefly mentioned in the introduction as well. Since it is prudent for a security professional to perform due diligence on critical security mechanisms, flagging suspicious changes to CA policy helps with performing that due diligence.
2) Not all changes are particularly done by an attacker or malicious insider. Sometimes a human mistake might lead to weakening the authentication process for a specific scenario, making initial access easier to perform (CA policies can sometimes be complex after all). By flagging a change that might open that initial access procedure, you make sure the human mistake gets raised and remediated in a timely matter.
3) We will go deeper into this during the remainder of this post, but there are quite a couple of Entra ID roles that can change configurations leading to being able to perform defense evasion on CA policies. More roles than most people can think of actually.
CA modification logging
Before we go into the procedures I want to talk about what the logging of CA policy modifications look like. Most of the changes can be found in the TargetResources column. This is a JSON Array where each object has the following properties:
- administrativeUnits
- displayName
- id
- modifiedProperties
- type
As you can probably guess, the interesting data lives in the modifiedProperties. This is again a JSON Array, containing objects with the displayName of the modified object, and all the old values before the change and the new values after the change in their own object.
This means we have to parse both objects in order to find the changes that happen during the policy modification. To parse these objects, I used the following query:
AuditLogs
// Get CA updates
| where OperationName == "Update conditional access policy"
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Get the old and new values
| extend NewValue = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue)))
| extend OldValue = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue)))
T1556.009 procedures
Deleting a policy
The first procedure is probably one of the most easiest but obvious ways to evade ca policies. One can simply just delete a conditional access policy. It is not very stealthy, but very effective 😏.
I think that unless an administrator is testing out some new features, this event should almost never happen in an organization. It can easily be detected with the below rule
AuditLogs
// Get CA updates
| where OperationName == "Delete conditional access policy"
Changing the policy mode
The second procedure is also an easy one, where a ca policy is switched from 'On' mode to 'Report-only' or 'Off'.
This one is also easy to detect, but requires some more parsing of the 'TargetResources' column
AuditLogs
// Get CA updates
| where OperationName == "Update conditional access policy"
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Get the old and the new state
| extend NewState = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).state
| extend OldState = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue))).state
// Flag state change to inactive
| where tostring(OldState) == "enabled" and tostring(NewState) != "enabled"
Include and Exclude object changes
The next procedure is to fiddle with the include and exclude objects in a CA policy. This is way more subtle since an attacker can add an object such as a user, application, location, etc to an exclude setting, or remove an object from an include setting to evade the policy.
Here I found it important that these changes are only interesting when an object is added to an exclude, or removed from an include. When it happens the other way around, the policy becomes more strict making it more prone for Benign Positive detections (which we want to limit of course).
To detect this, I count the number of objects in the array of all the new values, and do the same for the old values. When the length of the array of one of the new values is bigger than the length of the array of one of the old values for an exclude object, we throw an alert. When the new array length for an include object is lower than the old array length, we also throw an alert.
Before:
After:
Unfortunately, this does not work for all changes. For below scenario's I recommend to flag any change since there are too much options to fiddle with:
- Application filter changes
- Client App type changes
- Sign-in and user risk level changes
- Service Principal risk level changes
- Device condition changes
Additionally, we need to make sure all changes from 'All' to a specific object in includes are flagged as well. This because a change from 'All' to one specific object does not resolve in a count change of the array, evading the previous detection.
When we put this all together, you get below query:
AuditLogs
// Get CA updates
| where OperationName == "Update conditional access policy"
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Save the new and old values
| extend NewValueConditions = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).conditions
| extend OldValueConditions = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue))).conditions
// Count the new inlude arrays
| extend CountNewUserIncludes = array_length(NewValueConditions.users.includeUsers),
CountNewRoleIncludes = array_length(NewValueConditions.users.includeRoles),
CountNewGroupIncludes = array_length(NewValueConditions.users.includeGroups),
CountNewUserActionIncludes = array_length(NewValueConditions.applications.inlcudeUserActions),
CountNewAuthContextIncludes = array_length(NewValueConditions.applications.includeAuthenticationContextClassReferences),
CountNewApplicationIncludes = array_length(NewValueConditions.applications.inlcudeApplications),
CountNewLocationIncludes = array_length(NewValueConditions.locations.includeLocations),
CountNewPlatformIncludes = array_length(NewValueConditions.platforms.includePlatforms)
// Count the old inlude arrays
| extend CountOldUserIncludes = array_length(OldValueConditions.users.includeUsers),
CountOldRoleIncludes = array_length(OldValueConditions.users.includeRoles),
CountOldGroupIncludes = array_length(OldValueConditions.users.includeGroups),
CountOldUserActionIncludes = array_length(OldValueConditions.applications.inlcudeUserActions),
CountOldAuthContextIncludes = array_length(OldValueConditions.applications.includeAuthenticationContextClassReferences),
CountOldApplicationIncludes = array_length(OldValueConditions.applications.inlcudeApplications),
CountOldLocationIncludes = array_length(OldValueConditions.locations.includeLocations),
CountOldPlatformIncludes = array_length(OldValueConditions.platforms.includePlatforms)
// Count the new exclude arrays
| extend CountNewUserExcludes = array_length(NewValueConditions.users.excludeUsers),
CountNewRoleExcludes = array_length(NewValueConditions.users.exludeRoles),
CountNewGroupExcludes = array_length(NewValueConditions.users.excludeGroups),
CountNewApplicationExcludes = array_length(NewValueConditions.applications.excludeApplications),
CountNewLocationExcludes = array_length(NewValueConditions.locations.excludeLocations),
CountNewPlatformExcludes = array_length(NewValueConditions.platforms.excludePlatforms)
// Count the old exclude arrays
| extend CountOldUserExcludes = array_length(OldValueConditions.users.excludeUsers),
CountOldRoleExcludes = array_length(OldValueConditions.users.excludeRoles),
CountOldGroupExcludes = array_length(OldValueConditions.users.excludeGroups),
CountOldApplicationExcludes = array_length(OldValueConditions.applications.excludeApplications),
CountOldLocationExcludes = array_length(OldValueConditions.locations.excludeLocations),
CountOldPlatformExcludes = array_length(OldValueConditions.platforms.excludePlatforms)
// Alert when includes are taken away and excludes are added, application filter changes, or AppType changes
| extend Reasons = dynamic([])
| extend Reasons = iff(CountNewUserIncludes < CountOldUserIncludes, array_concat(Reasons, dynamic(["User removed from include"])), Reasons)
| extend Reasons = iff(CountNewRoleIncludes < CountOldRoleIncludes, array_concat(Reasons, dynamic(["Role removed from include"])), Reasons)
| extend Reasons = iff(CountNewGroupIncludes < CountOldGroupIncludes, array_concat(Reasons, dynamic(["Group removed from include"])), Reasons)
| extend Reasons = iff(CountNewUserExcludes > CountOldUserExcludes, array_concat(Reasons, dynamic(["User added to exclude"])), Reasons)
| extend Reasons = iff(CountNewRoleExcludes > CountOldRoleExcludes, array_concat(Reasons, dynamic(["Role added to exclude"])), Reasons)
| extend Reasons = iff(CountNewGroupExcludes > CountOldGroupExcludes, array_concat(Reasons, dynamic(["Group added to exclude"])), Reasons)
| extend Reasons = iff(CountNewUserActionIncludes < CountOldUserActionIncludes, array_concat(Reasons, dynamic(["User action removed from include"])), Reasons)
| extend Reasons = iff(CountNewAuthContextIncludes < CountOldAuthContextIncludes, array_concat(Reasons, dynamic(["Authentication context removed from include"])), Reasons)
| extend Reasons = iff(CountNewApplicationIncludes < CountOldApplicationIncludes, array_concat(Reasons, dynamic(["Application removed from include"])), Reasons)
| extend Reasons = iff(CountNewApplicationExcludes > CountOldApplicationExcludes, array_concat(Reasons, dynamic(["Application added to exclude"])), Reasons)
| extend Reasons = iff(CountNewLocationIncludes < CountOldLocationIncludes, array_concat(Reasons, dynamic(["Locations removed from include"])), Reasons)
| extend Reasons = iff(CountNewLocationExcludes > CountOldLocationExcludes, array_concat(Reasons, dynamic(["Locations added to exclude"])), Reasons)
| extend Reasons = iff(CountNewPlatformIncludes < CountOldPlatformIncludes, array_concat(Reasons, dynamic(["Platforms removed from include"])), Reasons)
| extend Reasons = iff(CountNewPlatformExcludes > CountOldPlatformExcludes, array_concat(Reasons, dynamic(["Platforms added to exclude"])), Reasons)
// Flag general changes
| extend Reasons = iff(tostring(NewValueConditions.applications.applicationFilter) != tostring(OldValueConditions.applications.applicationFilter), array_concat(Reasons, dynamic(["Application filter changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.clientAppTypes) != tostring(OldValueConditions.clientAppTypes), array_concat(Reasons, dynamic(["Client app type changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.userRiskLevels) != tostring(OldValueConditions.userRiskLevels), array_concat(Reasons, dynamic(["User risk levels changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.signInRiskLevels) != tostring(OldValueConditions.signInRiskLevels), array_concat(Reasons, dynamic(["Sign-in risk levels changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.servicePrincipalRiskLevels) != tostring(OldValueConditions.servicePrincipalRiskLevels), array_concat(Reasons, dynamic(["Service Principal risk levels changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.devices) != tostring(OldValueConditions.devices), array_concat(Reasons, dynamic(["Device conditions changed"])), Reasons)
// Flag Change from include 'all' to only include specifics (since this can evade the count detections)
| extend Reasons = iff(tostring(OldValueConditions.locations.includeLocations) contains "all" and tostring(NewValueConditions.locations.includeLocations) !contains "all", array_concat(Reasons, dynamic(["Include locations changed from all to specific"])), Reasons)
| extend Reasons = iff(tostring(OldValueConditions.platforms.includePlatforms) contains "all" and tostring(NewValueConditions.platforms.includePlatforms) !contains "all", array_concat(Reasons, dynamic(["Include platforms changed from all to specific"])), Reasons)
| extend Reasons = iff(tostring(OldValueConditions.users.includeUsers) contains "all" and tostring(NewValueConditions.users.includeUsers) !contains "all", array_concat(Reasons, dynamic(["Include users changed from all to specific"])), Reasons)
| extend Reasons = iff(tostring(OldValueConditions.applications.includeApplications) contains "all" and tostring(NewValueConditions.applications.includeApplications) !contains "all", array_concat(Reasons, dynamic(["Include applications changed from all to specific"])), Reasons)
// Chech if reason array is empty
| where Reasons != "[]"
| sort by TimeGenerated desc
It is important to keep in mind that this kind of detection can be circumvented by adding or removing a dummy object to an include or exclude. By doing this, the attacker makes two changes that not result in a change of the array length.
Changing grand and session controls
Another procedure that can be used is changing the grand and session controls of a policy. If a policy is in Block Access mode or requires some form of MFA, changing the control is also a way of evading the policy.
Since there are a lot of scenarios that can be performed here, I recommend flagging all grand and session control changes. These happen not that often after all.
AuditLogs
// Get CA updates
| where OperationName == "Update conditional access policy"
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Save the new and old values
| extend NewValueGrandControls = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).grantControls
| extend OldValueGrandControls = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue))).grantControls
| extend NewValueSessionControls = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).sessionControls
| extend OldValueSessionControls = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue))).sessionControls
| extend Reasons = dynamic([])
| extend Reasons = iff(tostring(NewValueGrandControls) != tostring(OldValueGrandControls), array_concat(Reasons, dynamic(["Grant controls changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueSessionControls) != tostring(OldValueSessionControls), array_concat(Reasons, dynamic(["Session controls changed"])), Reasons)
// Chech if reason array is empty
| where Reasons != "[]"
| sort by TimeGenerated desc
Adding trusted locations
Besides the fact that trusted locations are often used in CA policy exclusions, they also evade Entra ID Identity Protection since it reduces false positives in some risk detections. This means that if a malicious user adds a trusted named location, not only CA policy exclusions can be used, but risk based detective and preventive controls might also not work, such as risk-based CA policies and risk-based detection rules. To flag this, we can look for new named locations or updates to existing locations, and check if the named location is trusted or not.
AuditLogs
// Get named location changes
| where OperationName in ("Add named location", "Update named location")
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Always flag when the named location is trusted
| extend NewValueIsTrusted = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).isTrusted
| where NewValueIsTrusted == "true"
CA group changes
Another procedure that can be used is to add or remove users from groups used in the includes or excludes of a CA policy. By doing this a malicious user is establishing the same as discussed earlier, but without changing the CA policies itself.
Detective controls
The question is, how will you be tracking the specific groups used in CA policies? I think there are two options:
- Defining your CA groups in a Microsoft Sentinel watchlist
- Using a naming convention for your groups used in CA policies.
In the example below I used a naming convention 'CA-' for the groups used in CA policies. Simply because I think it is much more maintainable than having to rely on a static Microsoft Sentinel watchlist. If a membership change happens to a group matching the naming convention, we throw an alert.
let ca_naming_convention = "CA-";
AuditLogs
| where OperationName in ("Add member to group", "Remove member from group")
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Search for the display name of the edited group and find groups with CA naming convention
| where TargetResources_modifiedProperties.displayName == "Group.DisplayName" and TargetResources_modifiedProperties contains ca_naming_convention
If you want to take this one step further, you can again use the same logic as mentioned before where we only flag adding users to exclude groups and removing users to include groups. For this you will need to make a distinction between groups used as includes and excludes in your CA policies
let ca_include_naming_convention = "CA-Include";
let ca_exclude_naming_convention = "CA-Exclude";
let remove_from_include = AuditLogs
| where OperationName == "Remove member from group"
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Search for the display name of the edited group and find groups with CA naming convention
| where TargetResources_modifiedProperties.displayName == "Group.DisplayName" and TargetResources_modifiedProperties contains ca_include_naming_convention;
let add_to_exclude = AuditLogs
| where OperationName == "Add member to group"
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Search for the display name of the edited group and find groups with CA naming convention
| where TargetResources_modifiedProperties.displayName == "Group.DisplayName" and TargetResources_modifiedProperties contains ca_exclude_naming_convention;
union remove_from_include, add_to_exclude
Preventive controls
Besides the detections for this procedure, I also want to talk about the mitigative controls you can use to prevent unauthorized changes to groups used in CA policies. If you use a normal Entra ID security group, roles like User Administrators, Directory Writers, and Groups Administrators can change group memberships and have influence on CA policies. If you want to narrow this down, I recommend to use one of both solutions
- Entra ID Role Assignable groups
- Restricted management administrative units
Entra ID Role Assignable groups
With Entra ID Role Assignable groups, you make sure fewer roles are able to change group memberships. If you want to change a membership for this kind of groups, you need to be at least a Privileged Role Administrator to be able to do that. This can easily be accomplished by setting this switch to 'Yes' when creating a group:
However, there are some disadvantages for using these kind of groups:
- You must at least be a Privileged Authentication Administrator to change the credentials, reset MFA, or modify sensitive attributes for members and owners of role-assignable groups.
- Group nesting is not supported, meaning a group cannot be added as member or a role-assignable group.
Especially the first one is important to keep in mind. If you for some reason are adding end-users to this kind of groups for specific CA exclusions, your helpdesk which probably have the Helpdesk Administrator role will not be able to -for example- reset those users their passwords anymore. If this is a problem for your organization, you will need to use restrictive management administrative units.
Restricted management administrative units
I do not want too dive to deep into this feature, but with restricted management administrative units you can delegate administration of Entra ID objects like groups to specific administrators instead of roles. By doing this, you can add the groups used in CA policies to such an administrative unit and specifically choose which administrators are allowed to manage these groups. Keep in mind that at the time of writing this post, groups in these restricted AU's cannot be managed with Microsoft Entra ID Governance features such as Entitlement Management. But if you do choose to use this, Entra ID roles like User Administrators, Directory Writers, and Groups Administrators do not have influence on the CA policies anymore.
Excluding PIM activations
For some organizations, the discussed detections might still be too 'benign positive prone'. Although I do not really recommend it, you can add an exclusion to these detections by watching for PIM activations by the user who performed the CA changes. Here we can exclude the flagged changes when the justification of the PIM activation contains keywords related to Conditional Access policy changes like 'CA', 'Conditional Access', or 'Named locations'. This filters out 'legitimate' CA policy changes performed by admins, who are activating their role specifically to do these changes. !Be aware of the blind spot you build with this and consider if such an exclusion fits your organization!
AuditLogs
// Get PIM activations
| where TimeGenerated > ago(24h)
| where OperationName contains "completed (PIM activation)"
// Parse details
| parse AdditionalDetails with * "{\"key\":\"StartTime\",\"value\":\"" PimStartTime "\"" * "{\"key\":\"ExpirationTime\",\"value\":\"" PimExpirationTime "\"" * "{\"key\":\"Justification\",\"value\":\"" PimJustification "\"" *
// Only get CA related PIM justifications
| where PimJustification has_any ("Conditional Access", "CA", "Trusted", "Named", "Location")
// Extend and projects
| extend UserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| project OperationName, PimJustification, PimStartTime, PimExpirationTime, UserPrincipalName
Conclusion and final detection rule
In the first place, this post should help you detect suspicious config changes that might lead to conditional access evasion. This is not only done by monitoring conditional access modifications but also other parts of Entra ID that might have an influence on the policy enforcement such as trusted locations and groups.
To make sure changes to these policies are not being performed by unauthorized users, the following Entra ID roles should be well protected behind Privileged Identity Management:
- Global Administrator
- Security Administrator
- Conditional Access Administrator
Additionally, when using normal Entra ID security groups for conditional access exclusions, the following roles have influence on the enforcement of the policies:
- Groups Administrator
- Directory Writers
- Identity Governance Administrator
- Partner Tier1 Support
- Partner Tier2 Support
- User Administrator
Therefore, groups used in conditional access policies should be Entra ID role assignable security groups, or groups added to a Restricted Management Administrative Unit.
Below you can find a detection rule that contains all of the detection parts we talked about earlier. It might not be the most efficient Kusto query, but it is effective 😄:
// !! TO DO: CHANGE TO YOUR CA GROUP NAMING CONVENTION !!
let ca_include_naming_convention = "CA-Include";
let ca_exclude_naming_convention = "CA-Exclude";
// OPTIONAL - Get PIM activations with justifications for CA changes
let ca_pim_activations = AuditLogs
// Get PIM activations
| where TimeGenerated > ago(24h)
| where OperationName contains "completed (PIM activation)"
// Parse details
| parse AdditionalDetails with * "{\"key\":\"StartTime\",\"value\":\"" PimStartTime "\"" * "{\"key\":\"ExpirationTime\",\"value\":\"" PimExpirationTime "\"" * "{\"key\":\"Justification\",\"value\":\"" PimJustification "\"" *
// Only get CA related PIM justifications
| where PimJustification has_any ("Conditional Access", "CA", "Trusted", "Named", "Location")
// Extend and projects
| extend UserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
| project OperationName, PimJustification, PimStartTime, PimExpirationTime, UserPrincipalName;
// Get suspicious policy changes
let policy_changes = AuditLogs
// Get CA updates
| where TimeGenerated > ago(24h)
| where OperationName in ("Update conditional access policy", "Delete conditional access policy")
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Save the new and old values
| extend NewValueConditions = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).conditions
| extend OldValueConditions = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue))).conditions
| extend NewValueGrandControls = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).grantControls
| extend OldValueGrandControls = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue))).grantControls
| extend NewValueSessionControls = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).sessionControls
| extend OldValueSessionControls = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue))).sessionControls
| extend NewState = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).state
| extend OldState = parse_json(tostring(parse_json(TargetResources_modifiedProperties.oldValue))).state
// Count the new inlude arrays
| extend CountNewUserIncludes = array_length(NewValueConditions.users.includeUsers),
CountNewRoleIncludes = array_length(NewValueConditions.users.includeRoles),
CountNewGroupIncludes = array_length(NewValueConditions.users.includeGroups),
CountNewUserActionIncludes = array_length(NewValueConditions.applications.inlcudeUserActions),
CountNewAuthContextIncludes = array_length(NewValueConditions.applications.includeAuthenticationContextClassReferences),
CountNewApplicationIncludes = array_length(NewValueConditions.applications.inlcudeApplications),
CountNewLocationIncludes = array_length(NewValueConditions.locations.includeLocations),
CountNewPlatformIncludes = array_length(NewValueConditions.platforms.includePlatforms)
// Count the old inlude arrays
| extend CountOldUserIncludes = array_length(OldValueConditions.users.includeUsers),
CountOldRoleIncludes = array_length(OldValueConditions.users.includeRoles),
CountOldGroupIncludes = array_length(OldValueConditions.users.includeGroups),
CountOldUserActionIncludes = array_length(OldValueConditions.applications.inlcudeUserActions),
CountOldAuthContextIncludes = array_length(OldValueConditions.applications.includeAuthenticationContextClassReferences),
CountOldApplicationIncludes = array_length(OldValueConditions.applications.inlcudeApplications),
CountOldLocationIncludes = array_length(OldValueConditions.locations.includeLocations),
CountOldPlatformIncludes = array_length(OldValueConditions.platforms.includePlatforms)
// Count the new exclude arrays
| extend CountNewUserExcludes = array_length(NewValueConditions.users.excludeUsers),
CountNewRoleExcludes = array_length(NewValueConditions.users.excludeRoles),
CountNewGroupExcludes = array_length(NewValueConditions.users.excludeGroups),
CountNewApplicationExcludes = array_length(NewValueConditions.applications.excludeApplications),
CountNewLocationExcludes = array_length(NewValueConditions.locations.excludeLocations),
CountNewPlatformExcludes = array_length(NewValueConditions.platforms.excludePlatforms)
// Count the old exclude arrays
| extend CountOldUserExcludes = array_length(OldValueConditions.users.excludeUsers),
CountOldRoleExcludes = array_length(OldValueConditions.users.excludeRoles),
CountOldGroupExcludes = array_length(OldValueConditions.users.excludeGroups),
CountOldApplicationExcludes = array_length(OldValueConditions.applications.excludeApplications),
CountOldLocationExcludes = array_length(OldValueConditions.locations.excludeLocations),
CountOldPlatformExcludes = array_length(OldValueConditions.platforms.excludePlatforms)
// Alert when includes are taken away and excludes are added, application filter changes, or AppType changes
| extend Reasons = dynamic([])
| extend Reasons = iff(CountNewUserIncludes < CountOldUserIncludes, array_concat(Reasons, dynamic(["User removed from include"])), Reasons)
| extend Reasons = iff(CountNewRoleIncludes < CountOldRoleIncludes, array_concat(Reasons, dynamic(["Role removed from include"])), Reasons)
| extend Reasons = iff(CountNewGroupIncludes < CountOldGroupIncludes, array_concat(Reasons, dynamic(["Group removed from include"])), Reasons)
| extend Reasons = iff(CountNewUserExcludes > CountOldUserExcludes, array_concat(Reasons, dynamic(["User added to exclude"])), Reasons)
| extend Reasons = iff(CountNewRoleExcludes > CountOldRoleExcludes, array_concat(Reasons, dynamic(["Role added to exclude"])), Reasons)
| extend Reasons = iff(CountNewGroupExcludes > CountOldGroupExcludes, array_concat(Reasons, dynamic(["Group added to exclude"])), Reasons)
| extend Reasons = iff(CountNewUserActionIncludes < CountOldUserActionIncludes, array_concat(Reasons, dynamic(["User action removed from include"])), Reasons)
| extend Reasons = iff(CountNewAuthContextIncludes < CountOldAuthContextIncludes, array_concat(Reasons, dynamic(["Authentication context removed from include"])), Reasons)
| extend Reasons = iff(CountNewApplicationIncludes < CountOldApplicationIncludes, array_concat(Reasons, dynamic(["Application removed from include"])), Reasons)
| extend Reasons = iff(CountNewApplicationExcludes > CountOldApplicationExcludes, array_concat(Reasons, dynamic(["Application added to exclude"])), Reasons)
| extend Reasons = iff(CountNewLocationIncludes < CountOldLocationIncludes, array_concat(Reasons, dynamic(["Locations removed from include"])), Reasons)
| extend Reasons = iff(CountNewLocationExcludes > CountOldLocationExcludes, array_concat(Reasons, dynamic(["Locations added to exclude"])), Reasons)
| extend Reasons = iff(CountNewPlatformIncludes < CountOldPlatformIncludes, array_concat(Reasons, dynamic(["Platforms removed from include"])), Reasons)
| extend Reasons = iff(CountNewPlatformExcludes > CountOldPlatformExcludes, array_concat(Reasons, dynamic(["Platforms added to exclude"])), Reasons)
// Flag general changes
| extend Reasons = iff(tostring(NewValueConditions.applications.applicationFilter) != tostring(OldValueConditions.applications.applicationFilter), array_concat(Reasons, dynamic(["Application filter changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.clientAppTypes) != tostring(OldValueConditions.clientAppTypes), array_concat(Reasons, dynamic(["Client app type changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.userRiskLevels) != tostring(OldValueConditions.userRiskLevels), array_concat(Reasons, dynamic(["User risk levels changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.signInRiskLevels) != tostring(OldValueConditions.signInRiskLevels), array_concat(Reasons, dynamic(["Sign-in risk levels changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.servicePrincipalRiskLevels) != tostring(OldValueConditions.servicePrincipalRiskLevels), array_concat(Reasons, dynamic(["Service Principal risk levels changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueGrandControls) != tostring(OldValueGrandControls), array_concat(Reasons, dynamic(["Grant controls changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueSessionControls) != tostring(OldValueSessionControls), array_concat(Reasons, dynamic(["Session controls changed"])), Reasons)
| extend Reasons = iff(tostring(NewValueConditions.devices) != tostring(OldValueConditions.devices), array_concat(Reasons, dynamic(["Device conditions changed"])), Reasons)
// Flag Change from include 'all' to only include specifics (since this can evade the count detections)
| extend Reasons = iff(tostring(OldValueConditions.locations.includeLocations) contains "all" and tostring(NewValueConditions.locations.includeLocations) !contains "all", array_concat(Reasons, dynamic(["Include locations changed from all to specific"])), Reasons)
| extend Reasons = iff(tostring(OldValueConditions.platforms.includePlatforms) contains "all" and tostring(NewValueConditions.platforms.includePlatforms) !contains "all", array_concat(Reasons, dynamic(["Include platforms changed from all to specific"])), Reasons)
| extend Reasons = iff(tostring(OldValueConditions.users.includeUsers) contains "all" and tostring(NewValueConditions.users.includeUsers) !contains "all", array_concat(Reasons, dynamic(["Include users changed from all to specific"])), Reasons)
| extend Reasons = iff(tostring(OldValueConditions.applications.includeApplications) contains "all" and tostring(NewValueConditions.applications.includeApplications) !contains "all", array_concat(Reasons, dynamic(["Include applications changed from all to specific"])), Reasons)
// Flag state change to inactive
| extend Reasons = iff(tostring(OldState) == "enabled" and tostring(NewState) != "enabled", array_concat(Reasons, dynamic(["Policy was disabled"])), Reasons)
// Flag policy deletion
| extend Reasons = iff(OperationName == "Delete conditional access policy", array_concat(Reasons, dynamic(["Policy was deleted"])), Reasons);
// Get trusted named location changes
let named_locations = AuditLogs
// Get named location changes
| where TimeGenerated > ago(24h)
| where OperationName in ("Add named location", "Update named location")
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Always flag when the named location is trusted
| extend NewValueIsTrusted = parse_json(tostring(parse_json(TargetResources_modifiedProperties.newValue))).isTrusted
| where NewValueIsTrusted == "true"
// Add reason
| extend Reasons = dynamic([])
| extend Reasons = iff(OperationName == "Add named location", array_concat(Reasons, dynamic(["Trusted named location was added"])), Reasons)
| extend Reasons = iff(OperationName == "Update named location", array_concat(Reasons, dynamic(["Trusted named location was updated"])), Reasons);
// Get changes to groups used in CA policies
let remove_from_include_group = AuditLogs
| where TimeGenerated > ago(24h)
| where OperationName == "Remove member from group"
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Search for the display name of the edited group and find groups with CA naming convention
| where TargetResources_modifiedProperties.displayName == "Group.DisplayName" and TargetResources_modifiedProperties contains ca_include_naming_convention
// Add reason
| extend Reasons = dynamic([])
| extend Reasons = dynamic(["Member removed from include group used in CA policy"]);
let add_to_exclude_group = AuditLogs
| where TimeGenerated > ago(24h)
| where OperationName == "Add member to group"
// Expand Target resources and the modified properties
| mv-expand TargetResources
| mv-expand TargetResources.modifiedProperties
// Search for the display name of the edited group and find groups with CA naming convention
| where TargetResources_modifiedProperties.displayName == "Group.DisplayName" and TargetResources_modifiedProperties contains ca_exclude_naming_convention
// Add reason
| extend Reasons = dynamic([])
| extend Reasons = dynamic(["Member added to exclude group used in CA policy"]);
// Union all detections
union policy_changes, named_locations, remove_from_include_group, add_to_exclude_group
// Check if reason array is empty
| where Reasons != "[]"
// Sorting and project
| sort by TimeGenerated desc
| project TimeGenerated, OperationName, InitiatedBy, LoggedByService, Result, TargetResources, AADOperationType, Reasons
| extend UserPrincipalName = tostring(InitiatedBy.user.userPrincipalName)
// Look for PIM activations from the same user who performed changes
| join kind=leftouter ca_pim_activations on UserPrincipalName
| project-away UserPrincipalName1
// Check if PIM was justified for user, and only show non-justified PIMs
| extend JustifiedPIM = iff(isnotempty(PimStartTime) and isnotempty(PimExpirationTime) and TimeGenerated between (todatetime(PimStartTime) .. todatetime(PimExpirationTime)), true, false)
| where JustifiedPIM == false