Wiring Microsoft Security Exposure Management Into Sentinel - Triage with Asset Criticality and Attack-Path Context
2026.04.23
If you run Microsoft Sentinel and Defender XDR side by side, you have felt the gap. Sentinel knows what happened. Defender knows what it happened to. The exposure graph that sits underneath Defender - asset criticality, relationships, attack paths - is exactly the context you want when you triage. The wiring to actually surface that context inside Sentinel queries and analytics rules is a series of small integrations you have to build yourself.
This is a lab-and-architecture take on what those integrations look like, what KQL patterns produce real triage value, and where entity matching quietly bites. Two alerts at the same severity and the same TTP, on entities the analyst has never heard of - the difference between “page someone” and “auto-close” is rarely in the detection logic. It is in the answer to how exposed the asset is and how close it sits to something that matters.
Two things up front. The ExposureGraphNodes and ExposureGraphEdges tables that this post leans on are documented as public preview in the Defender XDR advanced hunting reference at the time of writing - Microsoft can change the schema. The KQL patterns below mirror the documented Microsoft Learn examples for the exposure graph, but anything you build on top in production should plan for schema drift. And the framing here is lab and pattern. I have not rolled this out in a customer environment, so the limitations called out at the end are based on the public docs, not war stories.
What MSEM Hands You That Sentinel Doesn’t
Microsoft Security Exposure Management consolidates three things that Sentinel does not, by itself, model:
- Critical Asset Management. A classification layer that marks devices, identities, user groups, cloud resources, and external assets as business-critical. The Microsoft Learn overview lists predefined classifications for cases like file servers and domain controllers, sensitive databases, privileged identity groups (Power Users, Privileged Role Administrator), and cloud resources from Azure, AWS, and GCP. You can add custom classifications via query. Each critical asset carries a criticality level.
- The Exposure Graph. A node-and-edge model exposed in Defender XDR advanced hunting as
ExposureGraphNodesandExposureGraphEdges. Nodes are entities (devices, identities, virtual machines, storage, containers, network assets). Edges are typed relationships - documented examples include “Can Authenticate As”, “CanRemoteInteractiveLogonTo”, “routes traffic to”, “is running”, “contains”, “affecting”. - Attack Paths. Precomputed traversals through the graph that connect an exposed entry point to a critical asset. The graph itself is queryable from advanced hunting; the user-facing attack-path views and choke-point analysis sit in the Defender portal UI and the Exposure Management surface.
Sentinel has had entities, watchlists, and UEBA for years. None of those answer “is this host a domain controller”, “how many critical assets can this account reach in one hop”, or “did this asset’s exposure posture change in the last week”. MSEM does, and the data is shared, not siloed inside the Defender portal.
What Actually Flows Into Sentinel
There are two realistic ways the exposure data ends up usable from the Sentinel side.
1. Sentinel running in the Defender portal. As Sentinel completes its move into the Defender portal (March 31, 2027 is the Azure portal sunset, per the official transition timeline), advanced hunting and Sentinel hunting share a query plane. You can reference ExposureGraphNodes and ExposureGraphEdges from the same hunting surface that touches SecurityAlert and the Defender device tables. No extra ingestion required. This is the cleanest path and the one new deployments should plan around.
2. Custom ingestion via Logic App or Function App. The Microsoft Defender XDR data connector for Sentinel does not currently list ExposureGraphNodes or ExposureGraphEdges among the advanced hunting tables it can stream into the workspace - the published table set covers the Device*, Email*, Identity*, CloudAppEvents, AlertInfo, and AlertEvidence families, not the exposure graph. If you need the data inside a workspace that is not yet operating from the Defender portal, you have to materialise it yourself: a scheduled Logic App or Function App that calls the Defender Exposure Management API or the Advanced Hunting API and writes the slice you care about into a custom log table or refreshes a watchlist.
Licensing is the thing to confirm before you plan the integration, not after. Critical Asset Management and the broader exposure graph require the right Defender bundle, and the bundling has shifted in the last twelve months. As a directional sketch, full coverage typically requires Defender for Endpoint Plan 2, Defender for Servers Plan 2, or Defender Vulnerability Management as an add-on, depending on the asset class. Confirm against the current Microsoft Learn matrix for your tenant.
Latency: the exposure graph is rebuilt on a schedule, not in real time. For triage that is fine - you are answering “how important is this asset” and “what does it touch”, not “what is happening this millisecond”. Do not drive blocking actions off the graph.
Three Enrichments That Change the Call
Most teams reach for the same three patterns. None of them are clever. All three move the needle.
1. Asset Criticality Lookup
The simplest enrichment, and the one to land first. The pattern below mirrors the documented Microsoft Learn example for filtering critical devices: filter on Categories, read the nested criticalityLevel.criticalityLevel field, and project the identifiers you will need later for joins.
// Critical devices from the exposure graph.
// criticalityLevel.criticalityLevel: lower number = more critical. The MS Learn examples
// use < 4 to mean "critical". Confirm the scale in your tenant before driving rules off it.
let CriticalDevices =
ExposureGraphNodes
| where set_has_element(Categories, "device")
| where isnotnull(NodeProperties.rawData.criticalityLevel)
| extend CriticalityLvl = toint(NodeProperties.rawData.criticalityLevel.criticalityLevel)
| where CriticalityLvl < 4
| project NodeId, NodeName, NodeLabel, Categories, EntityIds, CriticalityLvl;
CriticalDevices
| sort by CriticalityLvl asc
EntityIds is the cross-system identifier blob - it carries values keyed by identifier type (for example MdeMachineId, AzureResourceId, DeviceInventoryId) and is what you use to join back to Defender hunting tables or to SecurityAlert entities. Identities work the same way against set_has_element(Categories, "identity"). Wrap the query as a saved Sentinel function once you have settled on the projection, and every analytics rule, hunt notebook, and workbook references the same source of truth.
2. Blast Radius
A non-critical device is not necessarily a non-critical compromise. The next-hop question is what changes triage: from this entity, how many critical assets are reachable, and through what relationships?
// One-hop blast radius for a single device, identified by display name.
// SourceNodeCategories is a real column on ExposureGraphEdges - filter directly on it
// instead of round-tripping through the nodes table.
ExposureGraphEdges
| where set_has_element(SourceNodeCategories, "device")
| where SourceNodeName == "WIN-FINANCE-07"
| join kind=inner (
ExposureGraphNodes
| where set_has_element(Categories, "device") or set_has_element(Categories, "identity")
| where isnotnull(NodeProperties.rawData.criticalityLevel)
| extend CriticalityLvl = toint(NodeProperties.rawData.criticalityLevel.criticalityLevel)
| where CriticalityLvl < 4
| project TargetNodeId = NodeId, TargetCrit = CriticalityLvl, TargetName = NodeName
) on TargetNodeId
| summarize
CriticalNeighbors = dcount(TargetNodeId),
EdgeLabels = make_set(EdgeLabel, 10),
Reaches = make_set(strcat(EdgeLabel, " -> ", TargetName), 10)
by SourceNodeName
For triage, a one-hop count is usually enough signal. Multi-hop traversals against ExposureGraphEdges are possible with the make-graph and graph-match operators (the Microsoft Learn page on the enterprise exposure graph has good examples), but they get expensive on a large graph. If you need them, lean on the precomputed attack paths from the Defender portal rather than recomputing them in KQL on every alert.
3. Recent Exposure Delta
The third pattern catches cases where an asset’s posture changed shortly before the alert. A device that was non-critical and well-patched yesterday and is suddenly internet-exposed today is a different incident from one that has been in that state for six months.
In practice this is a daily snapshot: write a small summary table from ExposureGraphNodes (or pull the per-asset exposure score from the API), and at incident creation, compare today’s value to seven days ago. Severity gets a boost when the delta is negative within the alert window. This is the highest-leverage of the three, and also the one that takes the most plumbing to do well.
Wiring It Up
Three concrete pieces, in build order.
Step 1: A Critical Assets watchlist. Watchlists are the cleanest enrichment surface in Sentinel - cheap to query, easy to reference from analytics rules, schedule-refreshable. A Logic App runs the criticality lookup above against advanced hunting on a schedule, flattens the parts of EntityIds you actually need, and updates a watchlist named CriticalAssets:
| AssetType | NodeName | MdeMachineId | AzureResourceId | AccountObjectId | AccountUpn | CriticalityLevel | NodeLabel |
|---|
Set the SearchKey to whichever identifier your most common alert source uses (for Defender alerts, MdeMachineId is usually the cleanest match). The other identifier columns are deliberately redundant - you will need different ones for different alert sources, and watchlists are too small to justify normalising them away.
Step 2: A KQL function for enrichment. Wrap the watchlist join so analytics rules do not repeat boilerplate.
// fnEnrichWithExposure - takes a table with DeviceName and adds criticality columns.
// Lower CriticalityLevel = more critical. EffectiveCriticality coalesces "not on the list"
// to 99 so it sorts after real levels.
let fnEnrichWithExposure = (T:(DeviceName:string)) {
T
| extend DeviceNameNorm = tolower(DeviceName)
| join kind=leftouter (
_GetWatchlist('CriticalAssets')
| where AssetType == "device"
| extend WatchKey = tolower(NodeName)
| project WatchKey,
CriticalityLevel = toint(CriticalityLevel),
NodeLabel,
MdeMachineId
) on $left.DeviceNameNorm == $right.WatchKey
| extend
IsCriticalAsset = isnotnull(CriticalityLevel),
EffectiveCriticality = coalesce(CriticalityLevel, 99)
};
// Defender-XDR-routed alerts surface device entities with HostName + DnsDomain.
// Build the lookup key the same way you stored it in the watchlist.
SecurityAlert
| mv-expand Entity = parse_json(Entities)
| where tostring(Entity.Type) =~ "host"
| extend DeviceName = tolower(strcat(tostring(Entity.HostName), ".", tostring(Entity.DnsDomain)))
| where isnotempty(DeviceName) and DeviceName != "."
| invoke fnEnrichWithExposure()
That mv-expand Entity step matters. The Entities field on SecurityAlert is a JSON array, not a single object, and the first element is not always the host. Picking entities by type rather than by index is the difference between a function that works in production and one that quietly returns nothing for half your alerts.
Step 3: A severity-boosting analytics rule. The first rule that uses the function should not be a brand-new detection. It should be a meta-rule that re-evaluates incidents the existing detections are already creating.
// Re-rank recent incidents based on critical asset hits.
SecurityIncident
| where TimeGenerated > ago(1h)
| mv-expand AlertIds
| extend AlertId = tostring(AlertIds)
| join kind=inner (
SecurityAlert
| mv-expand Entity = parse_json(Entities)
| where tostring(Entity.Type) =~ "host"
| extend DeviceName = tolower(strcat(tostring(Entity.HostName), ".", tostring(Entity.DnsDomain)))
| invoke fnEnrichWithExposure()
| where IsCriticalAsset
| project SystemAlertId, EffectiveCriticality, NodeLabel
) on $left.AlertId == $right.SystemAlertId
| summarize
MinCriticality = min(EffectiveCriticality),
HitLabels = make_set(NodeLabel, 8)
by IncidentName, IncidentNumber, Severity
| extend ProposedSeverity = case(
MinCriticality <= 1, "High",
MinCriticality == 2, "Medium",
Severity)
Pair the rule with an automation rule that, on incident creation, calls a playbook to apply ProposedSeverity and add a tag derived from HitLabels. The original detection logic stays untouched, nothing gets suppressed, and the analyst’s queue reorders by what actually matters.
The Joins That Bite
This part is unglamorous and is where most enrichments quietly fail.
The Entities field on a SecurityAlert is a JSON array with multiple shapes per provider. Defender-routed alerts surface device entities with Type == "host", HostName, and DnsDomain. Identity entities arrive with Type == "account" and identifiers like AadUserId. Sentinel scheduled rules over custom logs surface whatever the rule author projected. The watchlist key has to handle all of them.
Rules of thumb worth internalising:
- Iterate, do not index.
parse_json(Entities)[0]reaches for the first element of an array that has no guaranteed order. Alwaysmv-expandand filter byEntity.Type. - Devices: normalize to lowercase FQDN.
tolower(strcat(HostName, ".", DnsDomain))against the MSEMNodeName(which for devices is typically already FQDN-shaped). When both sides have anMdeMachineIdavailable, prefer that - it is more stable than name-based matching. - Identities: prefer the Entra object id, fall back to UPN. Display names and SamAccountName are not unique. Object ids stay stable across renames.
- Watchlist refresh ordering matters. If Critical Asset Management adds a device at 09:05 and an alert fires at 09:07, an hourly watchlist refresh has not run yet. For high-trust enrichment, refresh more often or have the playbook call the Advanced Hunting API directly when the watchlist returns no hit.
EntityIdsis dynamic and varies by node type. It carries identifier-typed entries; depending on origin you might seeMdeMachineId,AzureResourceId,DeviceInventoryId, orSecurityIdentifier. Project the ones you need for joining; do not assume all are populated for all nodes.- Multi-tenant and multi-workspace. Critical Asset Management is per Defender tenant. Cross-tenant Sentinel federation does not propagate criticality. Split workspaces for sovereignty reasons need a watchlist per workspace.
Each of these looks fine in dev with three test devices and quietly breaks at production scale. Build with the bad cases in mind from the start.
What This Doesn’t Solve
Honesty section.
- It does not generate detections. MSEM tells you what matters; Sentinel tells you what happened. The graph alone will not fire on a missing event.
- Public preview status, schema drift. Per the Defender XDR advanced hunting reference,
ExposureGraphNodesandExposureGraphEdgesare documented as public preview. Microsoft is allowed to change column shape and JSON keys before GA. Anything you put in front of analysts should fail open if a field disappears. - The graph is not real time. Refresh cadence is on the order of hours, not seconds. Don’t drive blocking actions off it.
- Defender XDR connector does not stream the exposure graph. As of this writing, the Sentinel data connector for Defender XDR lists the device, identity, email, cloud-app, and alert tables.
ExposureGraphNodesandExposureGraphEdgesare not in the supported set. If you are still running Sentinel in the Azure portal and not yet onboarded into the Defender portal, you will be doing custom ingestion to land this data in the workspace. - Critical Asset rule logic is partially opaque. Predefined classifications are documented at a high level - domain controllers, sensitive databases, privileged identity groups, internet-exposed VMs - but the exact evaluation is not fully specified. Verify what falls in and out of scope by spot-checking the portal, not by trusting rule names.
- Region and sovereign-cloud constraints. The MSEM overview notes that Security Exposure Management data and capabilities are unavailable in U.S. Government clouds (GCC, GCC High, DoD). Confirm regional availability against Microsoft Learn before committing to architecture, especially if you operate split workspaces for sovereignty reasons.
- Coverage varies by asset class. Devices, identities, and Azure cloud assets are well represented. AWS and GCP coverage shipped with the Defender for Cloud integration into the portal but is younger; SaaS and OAuth applications are progressing but lag. If your critical-asset story leans on these classes, verify what is currently in the graph for your tenant.
What I’d Wire Up First
In order, smallest first:
- Critical Assets watchlist. One Logic App, one watchlist, one schema. Useful on day one even before any analytics rule references it - SOC analysts can query it manually during triage and that alone changes how alerts get handled.
fnEnrichWithExposuresaved function. Trivial to add. Once it exists, every new analytics rule and every hunt notebook pulls criticality for free.- One severity-boost analytics rule. Don’t try to retrofit the entire detection library at once. Pick the noisiest queue (privilege escalation alerts, sign-in anomalies, lateral-movement candidates) and apply the boost only there. Measure the change in mean time to triage on that queue, not on aggregate.
- Blast radius for the top-N nodes. Run a daily job that computes one-hop blast radius for the top few hundred nodes by edge count, write it to a custom table, and join against it from analytics rules. This is the cheapest way to get attack-path-adjacent value without hitting the API per alert.
- Exposure delta as a stretch goal. Useful, harder to get right. Build it last, after the first three are stable, and pilot it on a small slice before generalising.
The thing that makes this stick is that none of it is rip-and-replace. Every step adds enrichment to existing detections. The original rules keep firing, the analyst sees the same alerts, and the ones that matter rise to the top of the queue. That is the whole game.
If you build any of this and find a sharper KQL pattern - especially around multi-hop attack-path proximity that doesn’t melt the graph - I would be interested to hear about it. Reach me at marcel@graewer.com.
References
- Microsoft Learn - Microsoft Security Exposure Management overview
- Microsoft Learn - Critical asset management
- Microsoft Learn - Query the enterprise exposure graph
- Microsoft Learn - Schemas and operators in Microsoft Security Exposure Management
- Microsoft Learn - ExposureGraphNodes table reference
- Microsoft Learn - ExposureGraphEdges table reference
- Microsoft Learn - Stream data from Microsoft Defender XDR to Microsoft Sentinel
- Microsoft Learn - Watchlists in Microsoft Sentinel