Global Secure Access in Sentinel, Part 2: From Dashboards to Detections

Back in March, I showed you how to install the deadbolt: GSA file policies plus Purview DLP, blocking sensitive uploads at the network layer. A deadbolt is great. A deadbolt also has one glaring flaw. It cannot tell you who tried the handle, how many times, or whether they walked around back and tried the window next.

That’s what a doorbell camera is for.

In Part 1 of this series, we cleaned out the junk drawer: GSA logs streaming into Microsoft Sentinel, the official solution installed, and a workbook tab showing traffic, blocks, and policy hits. Today we finish the job. We’ll track down where your Purview DLP evidence actually lands (spoiler: it’s not one place), add a DLP tab to the workbook, correlate denied uploads with Defender device risk, and turn the patterns we find into analytics rules. In other words, we’re mounting the camera above the deadbolt.

What You’ll Need This Time

Everything from Part 1 carries over. On top of that:

  • GSA file policies and a Purview Inline web traffic DLP policy already configured. The deadbolt post walks through every step, so I won’t repeat it here.
  • Your Sentinel workspace onboarded to the Defender portal, so advanced hunting can see both your Sentinel tables and the Defender XDR tables in one place.
  • Optionally, the Microsoft Defender XDR data connector with event streaming enabled for DeviceInfo, AlertInfo, and AlertEvidence. More on why in the correlation section.

Note: The GSA and Purview network integration is still in preview as of this writing, with general availability targeted for this fall. Meanwhile, Microsoft is rolling out an extension of this integration (MC1419797) that inspects sensitive text and prompts at the network layer, not just files.

Where the Evidence Actually Lands

Here’s the part that trips people up. When a user’s upload gets blocked, the evidence doesn’t land in one tidy table. It scatters across a few places, and each one answers a different question.

SignalWhere it livesWhat you need
The network deny itselfNetworkAccessTraffic in your Sentinel workspace, with PolicyName and RuleName populated, assuming your filtering policy is linked to a security profile as covered in Part 1Part 1 diagnostic settings
DLP alerts and incidentsPurview Alerts, Defender XDR incidents, and the SecurityAlert table in SentinelDefender XDR incident integration (alert and incident ingestion is free)
Policy hit details in Activity ExplorerPurview portal, filtered by enforcement plane = networkNothing extra
Purview activity events for huntingDataSecurityEvents (preview) in advanced huntingInsider Risk Management alert sharing with Defender XDR opted in

That last row deserves a callout, and a warning. The DataSecurityEvents table reads like the richest hunting source here, with columns named DlpPolicyMatchInfo, SensitiveInfoTypeInfo, TargetUrlDomain, and DlpPolicyEnforcementMode. Microsoft populates it through Insider Risk Management, and the documented prerequisite is opting in to share insider risk data with Defender XDR. In my tenant that setting was already on and the table was still empty, which cost me an evening. The reason sits one layer further down. Insider Risk does not score a user until a triggering event fires, so all three of my policies sat at zero users in scope and the table had never received a record. If yours comes back empty, open Purview, then Insider Risk Management, then Policies, and read the Users in scope column before you assume the integration is broken. Zero there explains everything, and Start scoring activity for users is the button that fixes it.

Confirm the sources exist before you build

Part 1 spent a whole step on this and the lesson carries over. A query against a table that has never received data returns nothing without complaining, so confirm the sources are there before you build three tabs on top of them.

union isfuzzy=true
    DataSecurityEvents,
    SecurityAlert,
    DeviceInfo,
    AlertInfo
| summarize Events = count() by Type
| order by Events desc

Anything missing from that list is a tab you cannot build yet. No DataSecurityEvents rows means Insider Risk Management, either the sharing toggle or, more likely, no user in scope. No AlertInfo or DeviceInfo means the Defender XDR connector, or that you are running this in a workspace query window instead of advanced hunting. SecurityAlert present with no DLP rows in it means the incident integration is wired up and simply has not fired yet, which on a quiet tenant is the right answer rather than a broken query.

Tab 2: The DLP View

Back to our workbook from Part 1. Add a new tab and let’s answer the question the deadbolt can’t: who keeps trying the door?

Denies by file policy

NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize Denies = count() by PolicyName, RuleName, UserPrincipalName, DestinationFqdn
| top 20 by Denies

This is the workhorse. It shows which file policy is doing the heavy lifting, which rule inside it fired, and who keeps hitting it. A policy with zero hits after two weeks either means your users are angels or your CA scoping missed them. Trust me on this one, it’s usually the scoping.

Repeat offenders over time

NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize Denies = count() by bin(TimeGenerated, 1d), UserPrincipalName
| order by TimeGenerated asc

Render as a timechart. One deny is a mistake. The same user hitting the same block daily is either broken workflow, missing training, or someone probing for a way around your policy. All three deserve a conversation, just very different conversations.

Audit versus block mix

This one runs in advanced hunting in the Defender portal, since DataSecurityEvents lives on that side. Read the next few paragraphs before you build a tab on it:

DataSecurityEvents
| where Timestamp > ago(7d)
| extend Mode = case(
    DlpPolicyEnforcementMode == 0, "None",
    DlpPolicyEnforcementMode == 1, "Audit",
    DlpPolicyEnforcementMode == 2, "Warn",
    DlpPolicyEnforcementMode == 3, "Warn and bypass",
    DlpPolicyEnforcementMode == 4, "Block",
    DlpPolicyEnforcementMode == 5, "Allow",
    "Unknown")
| summarize Events = count(), Users = dcount(AccountUpn) by Mode, ActionType
| order by Events desc

The DlpPolicyEnforcementMode values are 0 (None), 1 (Audit), 2 (Warn), 3 (Warn and bypass), 4 (Block), and 5 (Allow), which is why the query spells them out rather than leaving you to decode integers in a chart. A policy showing heavy audit volume and zero blocks is a policy waiting for its production cutover decision, and this is how you make that case with data instead of vibes.

Now the warning. Once my table finally filled, it held 1,466 rows and not one of them was a network upload. Every ActionType was endpoint file activity: file deleted on endpoint, sensitive file read, file rename, removable media mounted, file synced from OneDrive. DlpPolicyMatchInfo was empty on all 1,466. So was TargetUrlDomain, which is the column I had planned the whole tab around. The first version of this query filtered on isnotempty(DlpPolicyMatchInfo) and grouped by TargetUrlDomain, and it returned zero rows against a table with fourteen hundred records in it.

The lesson generalizes past this one table. A documented column is a promise about schema, not a promise that your data will fill it. DataSecurityEvents is fed by Insider Risk Management, Insider Risk watches endpoints, and the GSA network plane is a different pipeline that lands in NetworkAccessTraffic and SecurityAlert instead. Build the tab on DlpPolicyEnforcementMode and ActionType, which are populated, and get your network story from Tab 2 where it actually lives.

DLP alert volume in Sentinel

SecurityAlert
| where TimeGenerated > ago(7d)
| summarize Alerts = count() by ProductName, AlertName, AlertSeverity
| order by Alerts desc
| take 10

Since alert and incident ingestion through the Defender XDR connector is free, this view costs you nothing extra and gives the SOC a Sentinel-native count of DLP alert activity. Notice there is no ProductName filter on that query, and that is deliberate. I first wrote it as ProductName has “Data Loss Prevention”, got an empty chart, and spent an evening convinced the integration was broken. It was not. Nothing had fired yet, and an empty result from a hardcoded filter looks exactly like an empty result from a dead pipeline. The day the file policy actually blocked something, the rows appeared under Microsoft Data Loss Prevention. Same trap as the Action column in Part 1, different column. Run SecurityAlert | distinct ProductName in your own workspace and filter on what is actually there rather than on what a blog post told you to expect.

Tying the block to the alert

Two posts ago the deadbolt went on the door. Everything above tells you it fired. This is the part where the camera and the deadbolt finally point at the same person.

When my test upload finally got stopped, the evidence arrived in two pieces. Global Secure Access logged nineteen blocked rows against the file policy, carrying the rule name, the user, the destination, and a TlsAction of Intercepted. Purview raised a separate High severity alert, categorized as Exfiltration, that knew a policy had been violated and nothing at all about the network path. Neither one is the whole story. Until you join them, an analyst is reading two grids and guessing they belong together.

Worth pausing on what this looks like from the other side of the screen, because it is not a security message and it does not mention policy, Purview, or Global Secure Access. The user gets a network error.

That red banner is the entire user-facing story of a blocked upload. ChatGPT tried to push the file to files.oaiusercontent.com, the request never completed, and the app reported the only thing it could see, which is that the network refused it. Note who it tells the user to go talk to. That is your help desk, and this is the ticket they are going to open, worded exactly like that.

This matters for two reasons. First, if you roll out a file policy without telling anyone, the failure mode your users experience is an unexplained upload error on a site they use every day, and they will retry it several times before they call. Those retries are the repeat hits in the chart above, not malice. Second, the FQDN in the error message is the same one sitting in DestinationFqdn on the blocked rows, which is what lets a help desk analyst close the loop in one query instead of escalating. Paste the host from the user’s screenshot into the Tab 2 grid and the policy name comes back with it.

Here is the join. It runs entirely against workspace tables, so unlike the Tab 3 queries below it needs nothing from advanced hunting and nothing from the Defender XDR connector:

let DlpAlerts = SecurityAlert
| where TimeGenerated > ago(1d)
| where ProductName has "Data Loss Prevention"
| mv-expand E = todynamic(Entities)
| where tostring(E.Type) == "account"
| extend AlertUpn = tolower(strcat(tostring(E.Name), "@", tostring(E.UPNSuffix)))
| project AlertTime = TimeGenerated, SystemAlertId, AlertName, AlertSeverity, AlertUpn;
NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| where PolicyName has "Confidential"
| extend Upn = tolower(UserPrincipalName)
| join kind=inner DlpAlerts on $left.Upn == $right.AlertUpn
| where AlertTime between (TimeGenerated .. TimeGenerated + 30m)
| summarize
    Blocks = dcount(TransactionId),
    Alerts = dcount(SystemAlertId),
    Destinations = make_set(DestinationFqdn, 5),
    FirstBlock = min(TimeGenerated),
    LastAlert = max(AlertTime)
    by UserPrincipalName, PolicyName, RuleName, AlertName

Swap Confidential for whatever your own file policy is called. Three things in that query are load bearing, and I got all three wrong on the first pass.

The identity is buried in Entities. CompromisedEntity came back empty on every DLP alert I looked at, which is what sent me down a dead end the first time and nearly cost this section its join. The account is in the Entities column instead, as JSON, so you have to mv-expand it and rebuild the UPN from Name and UPNSuffix. Nine alerts, nine account entities, every one populated. It is there. It just is not where you would look first.

Scoped properly, the answer is one row. One user, one file policy, nineteen blocked transactions against an OpenAI upload endpoint, and ten Purview alerts arriving in the minutes afterward. That row is the thing the deadbolt post could not give you: who tried the handle, which policy stopped them, and how many times they tried before giving up.

One honest limit before you build on this. The alert carried an account entity and nothing else. FileName and RemoteUrl came back empty, so the alert tells you who and which policy, not which file. The file name lives in Purview Activity Explorer, and it should also surface in DataSecurityEvents once a user is actually in Insider Risk scope, which is one more reason that table is worth chasing.

Tab 3: Correlation, or Why This Beats a Pretty Dashboard

A blocked upload from a healthy, patched laptop is a Tuesday. A blocked upload from a device Defender already flags as high exposure is a different animal entirely. Connecting those two signals is where this workbook earns its keep.

Denied traffic from exposed devices

The old trick of joining on UserPrincipalName is fragile, since DeviceInfo doesn’t carry a UPN column. The better join key is hiding in plain sight: GSA traffic logs carry the device ID, and DeviceInfo has AadDeviceId.

let ExposedDevices = DeviceInfo
| where Timestamp > ago(7d)
| where isnotempty(AadDeviceId)
| summarize arg_max(Timestamp, ExposureLevel, DeviceName) by AadDeviceId
| where ExposureLevel in ("Medium", "High");
NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| join kind=inner ExposedDevices on $left.DeviceId == $right.AadDeviceId
| summarize Denies = count() by DeviceName, UserPrincipalName, DestinationFqdn, ExposureLevel
| top 20 by Denies

The ExposureLevel column comes straight from Defender Vulnerability Management (Low, Medium, High). A high-exposure device repeatedly bouncing off your sensitive upload policy is exactly the row an analyst should see first thing in the morning.

Side note….. do not judge me for having a High Exposure Level in my lab. Trust me, I’m not a fan.

Vulnerable devices touching notable destinations

let VulnerableDevices = DeviceTvmSoftwareVulnerabilities
| summarize Vulns = dcount(CveId) by DeviceId
| where Vulns > 50;
let DeviceMap = DeviceInfo
| where isnotempty(AadDeviceId)
| summarize arg_max(Timestamp, AadDeviceId) by DeviceId, DeviceName;
NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| join kind=inner (
    VulnerableDevices
    | join kind=inner DeviceMap on DeviceId
) on $left.DeviceId == $right.AadDeviceId
| summarize Sessions = count() by DeviceName, DestinationFqdn
| top 20 by Sessions

Weak device posture plus repeated traffic to destinations you care about is a risky combination worth watching before it becomes an incident. Tune the vulnerability threshold to your environment; 50 is a starting point, not gospel.

Defender alerts landing near GSA denies

let GsaDenies = NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| project DenyTime = TimeGenerated, UserPrincipalName, DestinationFqdn;
AlertInfo
| where Timestamp > ago(1d)
| join kind=inner (
    AlertEvidence
    | where EntityType == "User"
    | project AlertId, AccountUpn
) on AlertId
| join kind=inner GsaDenies on $left.AccountUpn == $right.UserPrincipalName
| where abs(datetime_diff("minute", Timestamp, DenyTime)) < 30
| summarize Alerts = dcount(AlertId), Denies = count() by AccountUpn, Title, DestinationFqdn
| top 20 by Denies

An endpoint alert and a blocked upload from the same user inside a 30-minute window is the kind of overlap that turns two shrugs into one investigation. Those clusters are your strongest candidates for the analytics rules coming up next. Expect this one to come back empty most days, and on a quiet tenant that is the correct answer rather than a broken query. It needs an endpoint alert and a network block landing on the same identity inside the same half hour, which is rare by design. In my lab it stayed empty until I went looking for a way to make both fire at once.

From Workbook to Analytics Rules

Dashboards are for humans who happen to be looking. Analytics rules are for 2 AM. Once the workbook shows you which patterns matter in your environment, promote them.

Where analytics rules live, and how to make one

Quick detour, because Part 1 never needed this blade and the rest of this section assumes you can find it. Everything here is in the Defender portal, the same place you have been running the hunting queries. In the left navigation, open Microsoft Sentinel, then Configuration, then Analytics.

Three tabs across the top. Active rules is what is running right now. Rule templates is the catalog that content installs drop into your workspace. Anomalies is the machine learning baselines, which write to their own table and do not raise incidents on their own, so leave those alone for today.

Those three GSA rules did not come from me. They arrived with the Global Secure Access solution you installed in Part 1, which is what the Source name column is telling you. Installing content gives you templates, and some solutions also create active rules for you. Worth knowing which is which before you go build something that already exists.

Which brings up the two ways into this. If a template already covers what you want, go to Rule templates, find it, open it, and choose Create rule. The wizard opens pre-filled with the vendor’s query and MITRE mapping, and you tune from there. My tenant has 220 templates sitting in that tab, including the GSA one I lean on in Rule 3 below:

If nothing fits, which is the case for both rules below, use Create, then Scheduled query rule, and build it yourself. That opens a five tab wizard, and the settings tables I give for each rule map onto those tabs like this:

Wizard tabWhat you fill in
GeneralName, description, severity, MITRE tactic and technique, and whether the rule starts enabled.
Set rule logicThe KQL itself, entity mapping, custom details, then query scheduling, alert threshold, event grouping, and suppression.
Incident settingsWhether alerts become incidents, and how alerts group together into one incident.
Automated responseAutomation rules that fire on the incident. Skip it for now.
Review and createValidation. A red mark on a tab means something above it is wrong.

Four fields on that second tab do most of the work, and they are the ones people skip. Map entities is what turns a row of text into a clickable account or host in the investigation graph, and a rule without it is a notification rather than a detection. Run query every and Lookup data from the last are your frequency and lookback, and the lookback has to be at least as long as the frequency or you will drop events between runs. Generate alert when number of query results is the threshold, which stays at 0 when the query already carries its own threshold the way both of mine do. And Test with current data simulates fifty runs against real data and shows you the alert volume before you inflict it on anyone.

One field trips up almost everyone the first time. Your query has to return a column called TimeGenerated, because that is the reference point the scheduler uses for the lookback window. A summarize throws that column away, which is why both rules below end with extend TimeGenerated = LastSeen. Leave it off and the rule saves fine, then never fires.

Rule 1: Repeated denied sensitive uploads by one user

NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| where isnotempty(PolicyName)
| summarize
    Denies = count(),
    Destinations = make_set(DestinationFqdn, 10),
    Policies = make_set(PolicyName, 5),
    FirstSeen = min(TimeGenerated),
    LastSeen = max(TimeGenerated)
    by UserPrincipalName
| where Denies >= 5
| extend TimeGenerated = LastSeen
SettingValue
Rule typeScheduled
Frequency / lookbackEvery 1 hour / 1 day
SeverityMedium
Entity mappingAccount: FullName = UserPrincipalName
MITRE ATT&CKExfiltration, T1567
Incident groupingGroup by Account entity

The TimeGenerated = LastSeen alias matters, since scheduled rules use that field as their reference point after a summarize. In addition, group incidents by the Account entity so one persistent user creates one incident instead of flooding your queue with five.

Rule 2: High-exposure device with repeated denies

Take the exposed devices query from Tab 3, tighten it to ExposureLevel == “High”, add a Denies >= 3 threshold with the same FirstSeen/LastSeen pattern, and map both an Account entity (UserPrincipalName) and a Host entity (DeviceName). Severity High, since this is your context-rich signal. One catch: a Sentinel scheduled rule can only query the workspace, so this rule requires DeviceInfo streamed in through the Defender XDR connector. That ingestion cost buys you a detection neither the network layer nor the endpoint layer could produce alone.

Same join as Tab 3, tightened into a rule. Note the switch from Timestamp to TimeGenerated on DeviceInfo, which is the swap the heads-up above was talking about. A scheduled rule reads the workspace, not advanced hunting.

let ExposedDevices = DeviceInfo
| where TimeGenerated > ago(1d)
| where isnotempty(AadDeviceId)
| summarize arg_max(TimeGenerated, ExposureLevel, DeviceName) by AadDeviceId
| where ExposureLevel == "High";
NetworkAccessTraffic
| where TimeGenerated > ago(1d)
| where Action =~ "Block"
| join kind=inner ExposedDevices on $left.DeviceId == $right.AadDeviceId
| summarize
Denies = count(),
Destinations = make_set(DestinationFqdn, 10),
FirstSeen = min(TimeGenerated),
LastSeen = max(TimeGenerated)
by UserPrincipalName, DeviceName, ExposureLevel
| where Denies >= 3
| extend TimeGenerated = LastSeen

Rule 3: Already in the box

Resist the urge to build an IP-anomaly rule from scratch. GSA – Detect Abnormal Deny Rate for Source to Destination IP, from the Part 1 solution install, already learns a five day baseline and does this for you. One thing to check before you enable it: open the template’s query and confirm what it filters Action on. If it is looking for Denied, it will sit at zero forever for the same reason we covered in Part 1. Fix the filter when you create the rule from the template, then tune it and move on.

💡 Tip: Run every new rule with a generous threshold for the first two weeks, then tighten. Starting strict and drowning your SOC in day-one incidents is how detection engineering gets a bad name at your org.

Test It Like You Mean It

Before calling this done, run a repeatable test pass. The deadbolt post covers generating test hits with a sample sensitive file. Here’s the matrix I use:

ScenarioWhat it validatesExpected result
Upload a sensitive test PDF to a targeted AI siteEnd-to-end blockBlocked row in Tab 2 with your policy name, plus a DLP alert
Upload a benign file to an allowed siteBaseline behaviorAllowed traffic, no DLP signal
Repeat the blocked upload five timesRule 1 logicUser surfaces in the repeat offenders chart, Rule 1 fires
Trigger a block from a high-exposure lab deviceCorrelation joinDevice appears in Tab 3 with ExposureLevel populated
Trigger an endpoint alert for the same user within 30 minutes of a blockAlert proximity joinUser and alert title appear together in the third Tab 3 visual
Join the block to the DLP alert on user and timeCross-system correlationOne row per user and policy, with the alerts landing after the blocks

Wrapping Up: Deadbolt, Camera, and a Clean Drawer

Two posts ago, your logs were a junk drawer. One post ago, the drawer had dividers. Today the deadbolt finally has its doorbell camera: a DLP tab showing which policies fire and who keeps testing them, a correlation tab that separates routine denies from denies on devices Defender already distrusts, and analytics rules standing watch while you sleep.

The deadbolt keeps the data in the house. The camera tells you who tried the handle, and now it texts you about it too. That’s the difference between enforcement and visibility, and you need both.

Global Secure Access in Sentinel, Part 1: Logs Are Not a Strategy

Every house has a junk drawer. You know the one. Dead batteries, three takeout menus, a single Allen wrench, and a charger for a phone you sold in 2018. Everything went in because “we might need it later.” Nothing has come out since.

A Log Analytics workspace can turn into the junk drawer of your Microsoft cloud. We flip on diagnostic settings, feel productive for a day, and promise ourselves we’ll build dashboards “when things slow down.” Meanwhile the logs pile up, the ingestion bill grows, and the one night you actually need answers, you’re digging past the takeout menus at 2 AM.

Today we’re cleaning out the drawer. In this post, we’ll stream Global Secure Access (GSA) logs into Microsoft Sentinel, install the official GSA solution from the Content hub, and build a workbook view that answers real questions: who went where, what got blocked, and which policy did the blocking. In Part 2, we’ll bring Microsoft Purview Network Data Security into the picture, correlate GSA blocks with Defender device risk, and turn what the workbook shows us into analytics rules.

If you’re brand new to GSA, start with my post on blocking AI apps with Entra Internet Access, then come back. We’ll wait. In addition, if you want the enforcement side of this story, my recent post on GSA file policies and Purview shows you how to install the deadbolt. This series is about proving the deadbolt actually works.

What You’ll Need

Before we start, make sure you have the following in place:

  • Microsoft Sentinel enabled on a Log Analytics workspace.
  • Global Secure Access deployed with at least one traffic forwarding profile enabled (Microsoft traffic, Internet Access, or Private Access).
  • The Microsoft Entra ID data connector configured in Sentinel.
  • Security Administrator in Entra to configure diagnostic settings, plus Microsoft Sentinel Contributor on the workspace’s resource group to install Content hub solutions.

Nothing exotic here. If GSA is already forwarding traffic in your tenant, you’re most of the way there.

Step 1: Point the Logs at Sentinel

First things first, we need to tell Entra where to send everything. Sign in to the Entra admin center and head to Entra ID > Monitoring & health > Diagnostic settings, then select Add diagnostic setting.

Give it a name that future you will recognize. “diag-setting-1” is NOT that name. Trust me on this one.

In the Logs section, these are the GSA categories and where each one lands in your workspace:

Diagnostic categorySentinel tableWhat it gives you
NetworkAccessTrafficLogsNetworkAccessTrafficTransaction-level detail for every request through GSA: user, device, destination, policy, action
NetworkAccessConnectionEventsNetworkAccessConnectionEventsConnection lifecycle with identity, device, and PoP region context
NetworkAccessGenerativeAIInsightsNetworkAccessGenerativeAIInsightsGenerative AI and MCP activity, including prompt-level visibility (preview)
RemoteNetworkHealthLogsRemoteNetworkHealthLogsIPsec tunnel and BGP session health for remote networks
NetworkAccessAlertsNetworkAccessAlertsGSA’s native security and policy alerts

Select the categories you need, choose Send to Log Analytics workspace under Destination details, pick your Sentinel workspace, and save.

Heads up: NetworkAccessTraffic is transaction-level, which is a polite way of saying it’s chatty. Every HTTP request through GSA becomes a row. Turn on the categories you’ll actually use, then keep an eye on ingestion volume for the first week before you commit to all five everywhere.

Step 2: Install the Global Secure Access Solution

Microsoft ships an official GSA solution in the Sentinel Content hub, and it saves you from starting with a blank canvas. The package includes three workbooks and seven analytics rules.

To install it, sign in to the Defender portal and browse to Microsoft Sentinel > Content management > Content hub. Search for “Global Secure Access,” select the solution, and click Install.

Here’s what comes in the box on the workbook side:

  • Network Traffic Insights. The operational dashboard for GSA traffic across all three forwarding profiles. This one only needs the diagnostic settings from Step 1.
  • Enriched Microsoft 365 logs Workbook. Correlates OfficeActivity audit events with GSA traffic using UniqueTokenId as the join key. This requires the separate Microsoft 365 data connector in Sentinel, since OfficeActivity doesn’t come from Entra diagnostic settings.
  • MCP Servers Dashboard (preview). Visualizes MCP and AI agent traffic from the NetworkAccessGenerativeAIInsights table. If shadow AI is on your radar, and it should be, this is the one you came for. It also has a prerequisite the install won’t mention, which we’ll get to in Step 3.

The seven analytics rules are worth a quick look too:

RuleWhat it catches
GSA – TI Domain EntityGSA destinations matching threat intel domain IOCs
GSA – TI IP EntityDestination IPs matching threat intel IP IOCs
GSA – TI URL EntityDestination URLs matching threat intel URL IOCs
GSA – Detect Abnormal Deny Rate for Source to Destination IPDeny-rate spikes against a learned five-day baseline
GSA – Detect Protocol Changes for Destination PortsProtocol mismatches that suggest tunneling
GSA – Detect Source IP Scanning Multiple Open Ports100+ distinct ports hit in 30 seconds
GSA – Detect Connections Outside Operational HoursConnections before 8 AM or after 6 PM

Installing the solution gives you these as rule templates. You still have to create a rule from each one you want running, so don’t assume you’re covered the moment the install finishes.

Tip: That last rule assumes your company sleeps at night. If you have shift workers or a global footprint, tune the hours before enabling it, or enjoy a fresh batch of incidents every morning at 6:01.

Step 3: Make Sure the Data Actually Showed Up

Sentinel only creates a table once data lands in it. In other words, if you check five minutes after saving the diagnostic setting and see nothing, don’t panic. Give it time.

Once you’ve waited a bit, browse to Microsoft Sentinel > Configuration > Tables in the Defender portal and confirm the GSA tables exist. Only the categories that have actually received data will appear here, so a short list is normal early on.

For a faster sanity check, run this in a query window:

union isfuzzy=true
    NetworkAccessTraffic,
    NetworkAccessConnectionEvents,
    NetworkAccessAlerts,
    RemoteNetworkHealthLogs,
    NetworkAccessGenerativeAIInsights
| where TimeGenerated > ago(24h)
| summarize Events = count() by Type
| order by Events desc

The isfuzzy=true flag keeps the query from failing if one of the tables hasn’t been created yet. Tables that exist show a count, tables that don’t just stay quiet.

One table deserves its own warning, because it behaves differently from every other category in the list. NetworkAccessGenerativeAIInsights is a lake-only table. Microsoft’s table reference lists “Lake-only ingestion: Yes” in the attributes box, and what that means in practice is that a standard Analytics-tier workspace never creates the table at all. Not slowly, and not eventually. The diagnostic category saves happily with the box ticked, the portal gives you no warning, and the table simply never shows up in Configuration > Tables. The MCP Servers Dashboard workbook reads from that table, so it renders empty right along with it.

None of that means GSA missed anything. In my lab it was decrypting and parsing MCP conversations perfectly the whole time, down to the JSON-RPC method per event and the client and server names lifted out of the initialize handshake. All of it was sitting in Entra under Global Secure Access > Monitor > Gen AI Insights logs, current to the second. It just never crossed into Log Analytics.

One query settles which situation you’re in:

Usage
| where TimeGenerated > ago(30d)
| where DataType startswith "NetworkAccess"
| summarize TotalMB = sum(Quantity), Last = max(TimeGenerated) by DataType

If a data type shows bytes there, ingestion is working and you are only waiting on latency. If NetworkAccessGenerativeAIInsights never appears at any volume, nothing has ever been ingested and waiting will not change that. Treat the Entra blade as the source of truth for MCP visibility today. If you need it queryable in Sentinel, budget for Microsoft Sentinel data lake and verify it end to end before you build detections on top of it.

Step 4: Build the Overview Tab

The built-in Network Traffic Insights workbook is a solid starting point, and I recommend exploring it first. That said, the whole reason for this series is building a view tailored to your environment, one that we’ll extend with DLP and Defender correlation tabs in Part 2. For that, we need our own workbook.

Head to Microsoft Sentinel > Threat management > Workbooks, select Add workbook, and switch to edit mode. From here, each visual is just a query item. These four are my starting lineup.

Traffic volume by action

NetworkAccessTraffic
| where TimeGenerated > ago(24h)
| summarize Events = count() by bin(TimeGenerated, 15m), Action
| render timechart

Top destinations by action

NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| summarize Events = count() by DestinationFqdn, Action
| top 20 by Events

Render this one as a bar chart. It shows you at a glance which destinations dominate your traffic and whether they’re sailing through or bouncing off a policy.

Blocked traffic by user and policy

NetworkAccessTraffic
| where TimeGenerated > ago(7d)
| where Action =~ "Block"
| summarize Blocks = count() by UserPrincipalName, PolicyName, RuleName, DestinationFqdn
| top 20 by Blocks

This is the SOC-friendly view. Instead of making an analyst pivot across three blades, one grid ties the user, the policy, the specific rule, and the destination together. When someone asks, “why can’t I get to this site,” the answer is one row. Two caveats before you go looking for it. PolicyName and RuleName only populate for traffic an Internet Access filtering policy actually evaluated, so traffic riding the Microsoft profile leaves both columns empty. If nothing has tripped a block rule yet, the whole grid comes back empty, which is the right answer on a quiet tenant rather than a broken query.

Remote network health

RemoteNetworkHealthLogs
| where TimeGenerated > ago(24h)
| summarize Events = count() by RemoteNetworkId, Status

Skip this one if you’re not using remote networks. Sentinel won’t even create RemoteNetworkHealthLogs until a remote network reports in, though the query window resolves the schema anyway and just returns nothing rather than erroring. If you are using remote networks, this separates two very different stories: a blocked transaction while tunnels are healthy is a policy question, while widespread failures during a degraded tunnel is a network operations question. Knowing which fire you’re fighting is half the battle.

Here’s the finished tab after a night of real traffic. The timechart separates Allow from Block cleanly once both exist, the blocked-traffic grid ties one user to one policy, one rule, and one destination on a single row, and I tacked on a fifth tile for top cloud apps once CloudAppName turned out to be the enrichment column that actually populates.

Wrapping Up: One Drawer Down

Look at that. The junk drawer has dividers now. GSA logs are flowing into Sentinel, the official solution is installed, seven detection rules are ready to enable, and a workbook tab answers the questions that used to require three portals and a prayer.

Here’s the thing about junk drawers, though: organizing the drawer is only step one. The next step is noticing when someone tries to sneak something out of the house. If you followed my deadbolt post, your GSA file policies and Purview DLP rules are already blocking sensitive uploads. In Part 2, we’ll find out where those events actually land in Sentinel, add a DLP tab to this workbook, correlate blocked traffic with Defender device risk, and graduate from dashboards to detections.

Future you, the one who gets asked “did anyone upload customer data to that AI site,” will appreciate it.

Find Blocked Kiosk Apps at Scale with Intune Remediations

First, a small confession. I seem to have lucked out these past few years. Customer after customer has handed me enormous fleets of kiosk devices. Somewhere along the way, I actually started to enjoy the puzzle. When you work on that many kiosks, the patterns start jumping out at you. You stop troubleshooting each one from scratch and start building little tricks that get you to the fix fast. This is one of them.

In my last kiosk post, I did what most of us do when an app gets blocked. I temporarily added Event Viewer, Notepad, and Command Prompt to the kiosk, signed in at the console, opened the AppLocker log, and read the blocks by hand. It worked. It also meant I was the kiosk’s personal detective, showing up at the crime scene every single time.

That is fine when the kiosk is sitting on my bench. Kiosks do not live on my bench. They live in lobbies, break rooms, factory floors, and that one spot overseas under somebody’s desk that also happens to run the badge system. You cannot console into all of them, and you definitely do not want to.

So, this time, we flip it. Instead of you driving to the kiosk, the kiosk reports to you. By the end of this, every device will tell you which executables AppLocker blocked, that report lands in Intune, and you can read it per device without touching a single one of them.

A quick refresher on why kiosks block things

People forget what is actually doing the work here. When you build a multi-app kiosk with Assigned Access, Windows does not spin up some brand new lockdown engine. It generates AppLocker rules under the hood to allow the apps in your AllowedApps list, then enforces them. Anything not on the list gets denied.

Microsoft says this plainly in the Assigned Access policy settings docs. The kiosk allow list becomes AppLocker allow rules.

The catch is that the app you launch is rarely the thing that breaks. The launch chain is. I had a customer whose .bat file was sitting right there in the Allowed Apps list, running fine. That same .bat, however, called Find.exe. The main application worked without complaint, so on the surface everything looked healthy. One small piece of it leaned on Find.exe, which was not on the allow list, so that call got blocked and the feature quietly died. Trust me on this one, the helper binary is almost always the culprit.

The problem with doing this one device at a time

Reading Event Viewer by hand does not scale. It is slow, it requires physical or console access, and it only shows you one device. Meanwhile, you probably have a fleet of kiosks that all run the same app and might all be missing the same dependency.

What we actually want is a signal. Something each kiosk produces on its own, that we can read from one place, and that points straight at the missing executable. Intune Remediations gets us there.

Where the blocks actually live

AppLocker does not dump everything into one log. It splits events across a few channels under Applications and Services Logs > Microsoft > Windows > AppLocker. For kiosk troubleshooting, two of them matter.

LogWhat lands hereThe event ID we want
Microsoft-Windows-AppLocker/EXE and DLLBlocked Win32 executables and DLLs8004 (blocked, enforce mode)
Microsoft-Windows-AppLocker/Packaged app-ExecutionBlocked Store / UWP app launches8022 (blocked)

Event 8004 is the workhorse. 8004 means “was prevented from running” and only appears when AppLocker is in enforce mode, which is how Assigned Access runs. For blocked Store apps, the equivalent is 8022 in the Packaged app-Execution log.

One detail that will save you an hour of head-scratching if you script this yourself. AppLocker events do not store their data in the usual EventData block. They use UserData with a RuleAndFileData section, and the path lives in a FilePath field. The detection script below reads it from the right place, so you do not have to find that out the hard way like I did.

Turning it into an Intune Remediation

Here is where the kiosk starts snitching on itself. We build an Intune Remediation with a detection script only. No remediation script. We are not auto-fixing anything yet, we just want the device to report what got blocked, and Intune stores whatever the detection script writes to output.

A couple of behaviors matter for how we use this. First, a detection script that exits with code 1 tells Intune “issue detected,” which is what we want when blocks exist. Second, the output field is capped at 2,048 characters, so the script truncates before it hits that ceiling. Both are documented, and the script handles them.

The detection script

There is a catch with reading an event log on a schedule. The log is durable, but each run only looks at a slice of it. Picture a user hitting a block at 8 PM. If your scan window is short enough to keep the report current, that block is long gone by the time you sit down at 7 AM. Every run overnight saw it, reported it, then moved its window forward and forgot it. A block nobody was watching quietly vanishes.

Every run moves through four stages: it reads the AppLocker logs for a recent window (event 8004 for blocked Win32 executables, event 8022 for blocked Store apps), filters out the noise (the cmd.exe and powershell.exe troubleshooting binaries, plus the inbox Windows apps a kiosk denies by design), merges what it found into a small JSON file on the device, and writes the surviving list back out. That merge is the whole trick. The script does not rebuild the report from scratch each run. It remembers. A block from 8 PM is written to the file by the next run and is still sitting in the morning’s report. The file lives inside the Intune Management Extension log folder *\IntuneManagementExtension\Logs\KioskAppLockerBlocks.json, which is deliberate: Collect Diagnostics scoops up everything in that folder, so the full block history rides along in a support bundle without you touching the device. The output stays on one line, because Intune keeps only the last line of whatever a detection script prints. Two dials at the top steer the rest, and the next two sections explain exactly what each one changes.

# Detection script for Intune Remediations.
# Surfaces AppLocker-blocked apps for Assigned Access kiosk troubleshooting.
# Accumulates blocks into a small state file on the device, so a block that
# happened overnight is still in the report the next morning instead of aging
# out of a short scan window.
# Runs as SYSTEM. Output appears in "Pre-remediation detection output".

# --- Settings you can tune ---

# Per-run scan window: how far back each run reads the AppLocker log. Set this to at
# least your remediation interval. Wider is safe here, because results persist to disk
# between runs, so a generous window simply re-confirms blocks already recorded.
# Set ONE of these; $lookbackDays wins if it has a value.
$lookbackHours = 6       # comfortable for an hourly (or more frequent) schedule
$lookbackDays  = $null   # set instead (for example 2) if you run daily

# How long to keep a blocked app in the report after its most recent block. A fixed
# app stops generating blocks, so it ages out this many days after it was last seen.
# You can also clear a device immediately with the companion Clear scripts.
$retentionDays = 14

# Where the running list of blocks is stored on the device. This lives inside the
# Intune Management Extension log folder, so Collect Diagnostics pulls it in too.
$stateDir  = Join-Path $env:ProgramData 'Microsoft\IntuneManagementExtension\Logs'
$stateFile = Join-Path $stateDir 'KioskAppLockerBlocks.json'

$exeLog      = 'Microsoft-Windows-AppLocker/EXE and DLL'
$packagedLog = 'Microsoft-Windows-AppLocker/Packaged app-Execution'

# --- Exclusions. Add your own as you learn your environment. ---

# Win32 helpers to ignore, matched on file name (AppLocker logs %SYSTEM32% style paths).
$excludeWin32Names = @(
    'cmd.exe','powershell.exe','powershell_ise.exe','mmc.exe',
    'eventvwr.exe','notepad.exe','regedit.exe','reg.exe'
)

# Inbox / shell packaged apps a kiosk denies by design. Matched as substrings.
$excludeStoreNames = @(
    'CLIENT.OOBE','WEBEXPERIENCE','SHELLEXPERIENCEHOST','STARTMENUEXPERIENCEHOST',
    'SEARCHHOST','WINDOWS.SEARCH','CONTENTDELIVERYMANAGER','PEOPLEEXPERIENCEHOST',
    'SECHEALTHUI','CAPTURESERVICE','XGPUEJECTDIALOG','ACCOUNTSCONTROL',
    'ADDSUGGESTEDFOLDERS','PRINTQUEUEACTIONCENTER','CLIENT.CBS','CLIENT.CORE'
)

# --- Helpers ---

# AppLocker events store their payload under UserData/RuleAndFileData, not EventData/Data.
function Get-AppLockerFields {
    param([System.Diagnostics.Eventing.Reader.EventRecord]$Event)
    $result = [ordered]@{}
    try {
        $xml  = [xml]$Event.ToXml()
        $data = $xml.Event.UserData.RuleAndFileData
        if ($data) {
            foreach ($node in $data.ChildNodes) {
                if ($node.Name) { $result[$node.Name] = $node.InnerText }
            }
        }
    } catch {}
    return $result
}

# Fqbn looks like: <publisher DN>\<PackageName>\<Binary>\<Version>. Trim to the name.
function Get-StoreAppName {
    param([string]$Fqbn)
    if (-not $Fqbn) { return $null }
    $parts = $Fqbn -split '\\'
    if ($parts.Count -lt 2) { return $Fqbn }
    $pkg = $parts[1]
    $bin = if ($parts.Count -ge 3) { $parts[2] } else { $null }
    if ($bin -and $bin -notmatch '^(APPX|\*)$') { return "$pkg\$bin" }
    return $pkg
}

# --- Work out the scan window ---

if ($lookbackDays) {
    $start = (Get-Date).AddDays(-$lookbackDays)
} else {
    $start = (Get-Date).AddHours(-$lookbackHours)
}

# --- Collect this run's blocks ---

$current = New-Object System.Collections.Generic.List[object]

$blockedExe = Get-WinEvent -FilterHashtable @{
    LogName = $exeLog; Id = 8004; StartTime = $start
} -ErrorAction SilentlyContinue

foreach ($event in $blockedExe) {
    $path = (Get-AppLockerFields -Event $event)['FilePath']
    if ($path) {
        $leaf = ($path -split '\\')[-1]
        if ($leaf -and ($excludeWin32Names -notcontains $leaf.ToLower())) {
            $current.Add([pscustomobject]@{ Type = 'Win32'; Id = $path })
        }
    }
}

$blockedPkg = Get-WinEvent -FilterHashtable @{
    LogName = $packagedLog; Id = 8022; StartTime = $start
} -ErrorAction SilentlyContinue

foreach ($event in $blockedPkg) {
    $name = Get-StoreAppName -Fqbn (Get-AppLockerFields -Event $event)['Fqbn']
    if ($name) {
        $upper = $name.ToUpper()
        $skip  = $false
        foreach ($x in $excludeStoreNames) { if ($upper -like "*$x*") { $skip = $true; break } }
        if (-not $skip) {
            $current.Add([pscustomobject]@{ Type = 'Store'; Id = $name })
        }
    }
}

# --- Load the running state ---

$records = @()
if (Test-Path $stateFile) {
    try {
        $raw = [System.IO.File]::ReadAllText($stateFile)
        if ($raw.Trim()) { $records = @($raw | ConvertFrom-Json) }
    } catch { $records = @() }
}

$nowUtc = (Get-Date).ToUniversalTime()
$stamp  = $nowUtc.ToString('o')

# Index existing records by Type + Id for a quick merge.
$index = @{}
foreach ($r in $records) {
    if ($r -and $r.Type -and $r.Id) {
        $index["$($r.Type)|$($r.Id.ToUpper())"] = $r
    }
}

# Merge this run's blocks in: update LastSeen on known apps, add new ones.
foreach ($item in $current) {
    $key = "$($item.Type)|$($item.Id.ToUpper())"
    if ($index.ContainsKey($key)) {
        $index[$key].LastSeen = $stamp
    } else {
        $index[$key] = [pscustomobject]@{
            Type = $item.Type; Id = $item.Id; FirstSeen = $stamp; LastSeen = $stamp
        }
    }
}

# Age out anything not seen within the retention window.
$cutoff = $nowUtc.AddDays(-$retentionDays)
$kept = foreach ($r in $index.Values) {
    $seen = $null
    try {
        $seen = [datetime]::Parse($r.LastSeen, [cultureinfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::RoundtripKind)
    } catch {}
    if ($seen -and $seen -ge $cutoff) { $r }
}
$kept = @($kept)

# Save the running state (UTF-8, no BOM, so it round-trips cleanly).
try {
    if (-not (Test-Path $stateDir)) { New-Item -Path $stateDir -ItemType Directory -Force | Out-Null }
    $json = if ($kept.Count -gt 0) { $kept | ConvertTo-Json -Depth 3 } else { '[]' }
    [System.IO.File]::WriteAllText($stateFile, $json, (New-Object System.Text.UTF8Encoding($false)))
} catch {}

# --- Build the single-line output from the accumulated state ---
# Intune keeps only the last line of stdout, so everything goes on one line.

$win32 = @($kept | Where-Object { $_.Type -eq 'Win32' } | ForEach-Object { $_.Id } | Sort-Object -Unique)
$store = @($kept | Where-Object { $_.Type -eq 'Store' } | ForEach-Object { $_.Id } | Sort-Object -Unique)

$segments = @()
if ($win32) { $segments += 'Win32: '    + ($win32 -join '; ') }
if ($store) { $segments += 'StoreApps: ' + ($store -join '; ') }

if ($segments.Count -eq 0) {
    Write-Output "No blocked apps recorded in the last $retentionDays day(s)"
    exit 0
}

$output = $segments -join ' | '

# Intune caps detection output at 2,048 characters. Trim if needed.
if ($output.Length -gt 2000) {
    $shortWin32 = if ($win32) { (@($win32) | Select-Object -First 8) -join '; ' } else { '' }
    $shortStore = if ($store) { (@($store) | Select-Object -First 8) -join '; ' } else { '' }
    $segments = @()
    if ($shortWin32) { $segments += "Win32: $shortWin32" }
    if ($shortStore) { $segments += "StoreApps: $shortStore" }
    $output = ($segments -join ' | ') + ' | [truncated]'
}

Write-Output $output
exit 1

Heads up: The EXE and DLL log is chatty, and Microsoft warns it can get very verbose. Now that the state file is your memory, the log only needs to hold events long enough for one scan window to catch them. On a busy kiosk, raise the log’s max size so a block is not overwritten before the next run records it.

Tuning the two dials

The two settings at the top feel similar but do opposite jobs, so it is worth keeping them straight. $lookbackHours (or $lookbackDays) is the scan window, meaning how far back each run reads the log. Its only job is to cover the gap between runs so nothing slips through. Because results persist to the state file, the lookback is not your report window: a generous window is harmless, since it just re-reads blocks already recorded, while too short a window is the one real risk, because a block that lands in a run’s dead zone never gets seen. When in doubt, go wider. Run hourly, and 6 hours is comfortable. Run daily, and $lookbackDays = 2 gives a full day of overlap.

$retentionDays is the dial that actually shapes what you see. It decides how long a blocked app stays in the report after its most recent block, and it ages out by last seen, not first seen. That distinction does all the work. An app that is still being blocked keeps getting its timestamp refreshed on every run, so it never expires, which means live problems stay visible for exactly as long as they are live. Only two kinds of entries ever age out: apps you have already fixed, since they stop generating blocks and the clock starts ticking, and true one-offs that blocked once and never came back. So retention is really your “how long should a fixed app hang around” setting. Set it to 7 for a roughly weekly self-cleanup, or bump it to 10 or 14 if you review less often and want a rare one-off to survive until your next look.

Reading the results

Once the remediation runs, head to Devices > Scripts and remediations, pick your package, and open Device status. Turn on the Pre-remediation detection output column. That is where your script’s output shows up, per device. You will see something like this for a kiosk that is missing a couple of helpers:

Win32: %PROGRAMFILES%\VendorApp\Helper.exe; %PROGRAMFILES%\VendorApp\Updater.exe

The fleet view is where this pays off. You do not need to click into every device. The Device status view has an Export button that drops the same data, output field included, into a CSV. Sort it, count the most common blocked paths, and the dependency tripping up every kiosk running that app jumps right out.

For most admins, that is the entire job. Grab the output right from Intune, either the per-device column or the exported CSV, then go fix the XML. You do not need the Graph API, extra tooling, or any script beyond the one you already deployed.

Optional: If you already live in Graph and want to fold this into existing automation or a dashboard, the same run states sit behind the beta endpoint GET beta/deviceManagement/deviceHealthScripts/{id}/deviceRunStates, in the preRemediationDetectionScriptOutput field. Consider it a nice-to-have, not part of the core workflow. The portal export covers what you need here.

Note: Remediations are not instant. How often the script actually runs depends on the schedule you set on the assignment (once, hourly, or daily). If a fresh block does not show up right away, do not panic, give it a cycle.

Fixing what you find

This is the satisfying part. You have the blocked path, so now you add it to the kiosk XML. Drop it into the AllowedApps section as a DesktopAppPath entry, just like we did in the first post.

<App DesktopAppPath="%PROGRAMFILES%\VendorApp\Helper.exe" />

Expect one quirk here. AppLocker logs paths using its own variables like %SYSTEM32%, %PROGRAMFILES%, and %OSDRIVE%, not literal C:\ paths. Good news, DesktopAppPath accepts those variable paths, so in most cases you can paste what the report gives you. When in doubt, expand the variable to confirm the real location first.

Redeploy the updated XML, let it apply, and the block clears. Rinse and repeat until the detection output comes back clean, which is your signal that the kiosk has everything it needs.

Resetting the report after a fix

The detection script now remembers what it has seen, a fixed app does not vanish from the report right away. It lingers until $retentionDays passes with no new blocks. That is deliberate. The same persistence is what stopped you from missing the overnight block in the first place. When you want a device to forget on your schedule instead, clear its state file.

The cleanest way is a second, on-demand Remediation package built from two small companion scripts. The detection half flags the state file if it exists, and the remediation half deletes it:

# Detection: Detect-ClearKioskBlockState.ps1
$stateFile = Join-Path $env:ProgramData 'Microsoft\IntuneManagementExtension\Logs\KioskAppLockerBlocks.json'
if (Test-Path $stateFile) { Write-Output "State present."; exit 1 }
Write-Output "Nothing to clear."; exit 0
# Remediation: Remediate-ClearKioskBlockState.ps1
$stateFile = Join-Path $env:ProgramData 'Microsoft\IntuneManagementExtension\Logs\KioskAppLockerBlocks.json'
try {
    if (Test-Path $stateFile) { Remove-Item $stateFile -Force -ErrorAction Stop }
    Write-Output "Cleared."; exit 0
} catch { Write-Error "Failed: $_"; exit 1 }

I usually leave this as optional. It really depends on your level of OCD. Assign that package to nothing, then fire it with Run remediation (preview) against a single device once you have addressed its apps. The next detection run starts from an empty slate, so only apps that are still genuinely blocked come back. That clear-then-confirm loop is a more reliable “did my fix work” test than waiting for an entry to age out.

Wrapping Up

Last time, I was the kiosk’s detective, showing up at the scene one device at a time with Event Viewer in hand. This time, the kiosk files its own report before I even hear there is a problem. Same AppLocker logs, same fix in the XML, just delivered to me instead of me chasing it.

Here is the rhythm to keep:

  • Remember that Assigned Access is AppLocker underneath, so blocks land in the AppLocker logs.
  • Collect 8004 for Win32 and 8022 for Store apps, and read them from UserData, not EventData.
  • Ship a detection-only Remediation to a pilot kiosk group first, then widen it.
  • Read the per-device output in Intune, export the CSV for fleet-wide patterns, and add the missing paths to AllowedApps.
  • Keep going until the report comes back clean.

Ninjas Cloud PCs and Security

I almost made a comment about not realizing we’re in the middle of June, but then I remembered I don’t like folksy sayings about how time passes. What matters is there’s a lot going on.Some folks have reached out to me and apparently didn’t see this week’s videos

My six days at MMSMOA

Typically, I would do a short video recap of a week like I just had, but for some reason, I wasn’t feeling it. Maybe I’m just in a typing mood. Maybe it’s because I have some pics I want to share with you. Whatever the reason, we’re

PowerShell Best Practices for Intune Dont fight the functions

PowerShell functions provide a structured way to organize reusable code. In the context of Microsoft Intune and system administration, they enhance script readability, reduce duplication, and improve long-term maintainability. And while I completely understand not wanting to take “structured” advice from me, it will definitely help you.

PowerShell Best Practices for Intune Error Handling

In the last part we looked at why logging is so importing with the scripts we push to Intune. However, the best logging in the world can’t help you if we have nothing to log.So today we’ll about error handling, ensuring scripts are resilient, readable, and easy

PowerShell Best Practices for Intune Logging

Whether you like it or not, the ability to write PowerShell scripts is critical for effectively managing Windows devices with Microsoft Intune. I know a lot of folks have the sentiment of ‘wait, so now I’m a developer? Thanks a lot, Microsoft!’ But the way I see it…

Leverage Robopack Patch Groups to manage app personas

Understanding and managing user personas is not an easy thing within Intune. While there’s no silver bullet, I would highly recommend you look at a feature from Robopack called Patch Groups.Imagine not only being able to group your apps together for assignment, but also deploy them in

Advanced Intune Device Query Joining Across Categories

Last time, we went over the basics of Intune Device Query and how to pull data from the Device category using KQL. If you’ve been playing around with it, hopefully, you’re starting to see just how powerful it can be. But what if you need data from multiple