Skip to content
Ketan Kamble
Ketan Kamble
Modern Workplace Architect

Read-only Intune & Entra tooling, in the open — and what the portal is doing underneath.

MD-102 SC-300 Speaker · WNUG Finland

Which setting actually failed? Turning Intune non-compliance into a report

Cover: turning Intune non-compliance into a setting-level Power BI report

Intune will happily tell you a device is Not compliant. What it won't hand you — as data you can report on — is the why: which specific setting failed, where the user sits, and what make and model they're on. To learn that, you open the device. Then the next one. Then the next.

The payoff

“Non-compliant” with no reason means opening each device to hunt the setting that failed — minutes each, across thousands of devices. The setting-level report names the cause (BitLocker, firewall, min-OS…) up front, so you fix the policy instead of chasing devices. (Illustrative.)

The short version

Intune tells you a device is non-compliant — not which setting. This is a scheduled, read-only collector that turns setting-level failures into a sliceable Power BI report: no write access, no standing credentials, no live tenant connection.

Non-compliant, but on what?

Here's the view every Intune admin knows. A device shows a red Not compliant badge, you open it, and you finally see the truth: it's the Firewall, and Real-time protection, and the Defender signature — but the OS version and BitLocker are fine.

Intune device compliance blade — one device, its failing settings, the user's location and the device make/model, all in a single blade

Look at everything that lives on this one blade: the failing setting states, the primary user's location (Madrid), the manufacturer and model (a Lenovo ThinkPad X1 Carbon Gen 11). It's all there — for one device. To answer a question any manager actually asks — "how many devices are failing Firewall, and in which countries?" — you'd open this blade, read it, note it down, close it, and open the next device. Across a two-thousand-device fleet, that drill-down isn't a report. It's an afternoon. Several afternoons.

Before and after: the manual per-device grind replaced by a single read-only snapshot Animation: the manual per-device grind replaced by a single read-only snapshot

Why the built-in report doesn't close the gap

"Just export it," you might say. And Intune does have a Noncompliant devices report under Reports → Device compliance. But it gives you the device list — device name, user, compliance state, last check-in. It does not give you, as exportable data, the setting-level detail: which checks failed on which policy. That detail is the thing that makes the number actionable, and it's exactly the part that stays trapped behind the per-device blade.

So the question becomes: can I get the failing settings — for every non-compliant Windows device — as a single flat table I can slice? Yes. Just not from the portal's export button.

How it works: a read-only collector

The approach is a scheduled Azure Automation runbook. It authenticates with a Managed Identity — no stored secrets, no app registration keys — and calls Microsoft Graph with GET only. Nothing is ever written back to the tenant. The shape is list → per-device → per-policy — three levels:

  1. Find the non-compliant Windows devices (paged):

    GET /beta/deviceManagement/managedDevices
        ?$filter=operatingSystem eq 'Windows' and complianceState eq 'nonCompliant'
        &$select=id,deviceName,manufacturer,model,userPrincipalName,complianceState
    
  2. For each device, ask which policies it fails:

    GET /beta/deviceManagement/managedDevices/{id}/deviceCompliancePolicyStates
    
  3. For each failing policy, ask which settings failed:

    GET /beta/deviceManagement/managedDevices/{id}/deviceCompliancePolicyStates/{policyId}/settingStates
    

Each failing settingState becomes one rowDeviceName, the failing SettingName, its state, plus the user's city, country, department, and the device's make and model, enriched from a single cached users lookup. The runbook writes the result to a sanitized CSV in Blob storage.

The read-only Graph calls behind the report — a read-only sequence, Managed Identity, nothing written back

The permissions (least-privilege, read-only)

DeviceManagementManagedDevices.Read.All · User.Read.All. Both read-only. Every call is a GET.

The bit that surprises people: the N+1 fan-out

There is no single "give me all non-compliance detail" endpoint. It's a fan-out: one call for the device list, then one deviceCompliancePolicyStates call per device, then one settingStates call per failing policy on that device. On a large fleet that's thousands of calls — which is exactly why setting-level detail lives on a beta endpoint and almost nobody surfaces it. The payoff is the thing the portal won't give you: "device X is non-compliant specifically on Firewall and Real-time protection."

For example, one row might read CTS-4471 · Firewall · Not compliant · Madrid, ES · Lenovo ThinkPad X1 — one device, one failing setting, with the location and model already attached. Slice thousands of these by setting or by country in a single click.

One number everyone gets wrong. A device that fails three settings across two policies produces several rows. Count rows and your "non-compliant devices" number is inflated. The report counts distinct DeviceName, never rows — for the total, and for every per-setting, per-country and per-manufacturer cut alike.

The script

It's parameterised — no tenant, storage account, or container is hardcoded. Set three values at the top and run it as a runbook on a schedule.

View the full script — Collect-NonCompliantDevices.ps1
PowerShell
<#
.SYNOPSIS
    Zero-Access collector — Non-Compliant Windows Devices.

    Read-only Azure Automation runbook. Authenticates with a Managed Identity, queries Microsoft
    Graph with read-only (.Read.All) requests to find non-compliant Windows devices and the exact settings that
    failed, enriches with user context, pre-aggregates DISTINCT-DEVICE stats, and writes sanitized
    CSV snapshots to Blob storage. Nothing is ever written back to the tenant.

    Part of the Zero-Access Agent pattern: give the report the data, never the systems.
    Independent content — not affiliated with or endorsed by Microsoft.
#>

# ===========================================================================
#  CONFIGURE ME  ->  set these to your own values, then run.
#  These three lines are the only thing you MUST change.
# ===========================================================================
$ResourceGroup  = "<your-resource-group-name>"     # resource group that holds your storage account
$StorageAccount = "<your-storage-account-name>"    # storage account name (lowercase, globally unique)
$Container      = "<your-container-name>"           # blob container, e.g. "intune-report"
# ---------------------------------------------------------------------------
# Optional: also copy the root CSV to a secondary file share (another consumer).
# Leave empty to skip. Never put a real internal server name in a public repo.
$SecondarySharePath = ""                            # e.g. "\\your-server\share"
# ===========================================================================
# Safety net - stop if the placeholders above weren't replaced.
if ("$ResourceGroup $StorageAccount $Container" -match '<your-') {
    throw "Please set ResourceGroup, StorageAccount, and Container at the top of the script before running."
}

# --- Step 1: Initialize variables ---
$ExportLocation      = "$env:TEMP"
$LenovoModelFileName = "Lenovo_Model_Details.csv"
$OutputFileName      = "NonCompliant_Windows_Devices.csv"
$AgentFileName       = "NonCompliant_Windows_Devices_Agent.csv"
$StatsFileName       = "NonCompliant_Stats.csv"
$Today               = Get-Date -Format 'yyyy-MM-dd'
$ProgressPreference  = 'SilentlyContinue'
$VerbosePreference   = 'Continue'
Write-Verbose "Script started at $(Get-Date)"

# --- Step 2: Graph GET with pagination ---
Function Invoke-MyGraphGetRequest {
    Param ($URL)
    $AllResults = @()
    try {
        Do {
            $WebRequest   = Invoke-WebRequest -Uri $URL -Method GET -Headers $script:Headers -UseBasicParsing
            $ResponseData = ($WebRequest.Content | ConvertFrom-Json)
            $AllResults  += $ResponseData.value
            $URL          = $ResponseData.'@odata.nextLink'
        } While ($URL)
        Return $AllResults
    } catch {
        Write-Error "Failed to fetch data from ${URL}: $_"
        return $null
    }
}

# --- Step 3: Authenticate with the Managed Identity (Graph token, no secrets) ---
$url     = $env:IDENTITY_ENDPOINT
$headers = New-Object "System.Collections.Generic.Dictionary[[String],[String]]"
$headers.Add("X-IDENTITY-HEADER", $env:IDENTITY_HEADER)
$headers.Add("Metadata", "True")
$body = @{ resource = 'https://graph.microsoft.com/' }
$accessToken   = (Invoke-RestMethod $url -Method 'POST' -Headers $headers -ContentType 'application/x-www-form-urlencoded' -Body $body).access_token
$script:Headers = @{ 'Authorization' = "Bearer $accessToken" }
Write-Verbose "Access token obtained."

# --- Step 4: Fetch non-compliant Windows devices (read-only GET) ---
$DeviceQuery = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=operatingSystem eq 'Windows' and complianceState eq 'nonCompliant'&`$select=id,deviceName,manufacturer,model,userPrincipalName,lastSyncDateTime,complianceState"
$DeviceData  = Invoke-MyGraphGetRequest -URL $DeviceQuery
if (!$DeviceData) { Write-Error "Failed to fetch non-compliant device data."; return }
$DeviceCount = $DeviceData.Count
$nonCompliantDevices = $DeviceData
Write-Verbose "Total non-compliant Windows devices found: $DeviceCount"

# --- Step 5: Download Lenovo model lookup (optional enrichment) ---
try {
    $StorageAccountContext = (Get-AzStorageAccount -ResourceGroupName $ResourceGroup -Name $StorageAccount).Context
    $LocalFilePath = Join-Path -Path $ExportLocation -ChildPath $LenovoModelFileName
    Get-AzStorageBlobContent -Container $Container -Blob $LenovoModelFileName -Destination $LocalFilePath -Context $StorageAccountContext -Force | Out-Null
} catch {
    Write-Warning "Lenovo model lookup not available (continuing without it): $_"
}
$LenovoModelMapping = @{}
if (Test-Path $LocalFilePath) {
    Import-Csv -Path $LocalFilePath | ForEach-Object {
        if ($_.model.Trim() -ne "" -and $_.Model1.Trim() -ne "") {
            $LenovoModelMapping[$_.model.Trim().ToUpper()] = $_.Model1.Trim()
        }
    }
}

# --- Step 6: Fetch user context for each unique user (read-only GET) ---
$UserDetails    = @{}
$UserPrincipals = $DeviceData | Select-Object -ExpandProperty userPrincipalName -Unique
foreach ($upn in $UserPrincipals) {
    if (-not $upn) { continue }
    try {
        $UserDetailsURI = "https://graph.microsoft.com/v1.0/users?`$filter=userPrincipalName eq '$upn'&`$select=displayName,userPrincipalName,city,country,department,accountEnabled"
        $UserResponse   = Invoke-MyGraphGetRequest -URL $UserDetailsURI
        if ($UserResponse -and $UserResponse.Count -gt 0) {
            $u = $UserResponse[0]
            $UserDetails[$upn] = @{ City = $u.city; Country = $u.country; Department = $u.department; AccountEnabled = $u.accountEnabled }
        }
    } catch {
        Write-Warning "Could not get details for user $upn : $_"
        $UserDetails[$upn] = @{ City = "Unknown"; Country = "Unknown"; Department = "Unknown"; AccountEnabled = "Unknown" }
    }
}

# --- Step 7: Per device -> compliance policy states -> failing setting states ---
$results = @()
foreach ($device in $nonCompliantDevices) {
    $deviceId = $device.id
    try {
        # All compliance policy states for this device
        $policyStatesUrl = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates"
        $policyStates    = Invoke-RestMethod -Uri $policyStatesUrl -Headers $script:Headers -Method Get

        foreach ($policyState in $policyStates.value) {
            if ($policyState.state -ne "nonCompliant") { continue }   # only failing policies

            # Individual setting states under this failing policy
            $settingsUrl      = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$deviceId/deviceCompliancePolicyStates/$($policyState.id)/settingStates"
            $settingsResponse = Invoke-RestMethod -Uri $settingsUrl -Headers $script:Headers -Method Get

            $u = $UserDetails[$device.userPrincipalName]
            $lenovo = if ($device.manufacturer -like "*Lenovo*" -and $LenovoModelMapping.ContainsKey($device.model.ToUpper())) { $LenovoModelMapping[$device.model.ToUpper()] } else { "Unknown Model" }

            if ($settingsResponse.value.Count -eq 0) {
                if ($policyState.displayName -eq "Default Device Compliance Policy") {
                    $results += [PSCustomObject]@{
                        DeviceName = $device.deviceName; PrimaryUser = $device.userPrincipalName
                        City = $u?.City ?? "Unknown"; Country = $u?.Country ?? "Unknown"; Department = $u?.Department ?? "Unknown"; AccountEnabled = $u?.AccountEnabled ?? "Unknown"
                        Manufacturer = $device.manufacturer; Model = $device.model; LenovoModelDetails = $lenovo
                        LastSyncDateTime = $device.lastSyncDateTime; CompliancePolicyName = $policyState.displayName
                        IsCompliant = $false; SettingName = "Has a compliance policy assigned"; SettingState = "Non-compliant"; StateDetails = "No compliance policies assigned to this device"
                    }
                }
                continue
            }

            foreach ($setting in $settingsResponse.value | Where-Object { $_.state -eq "nonCompliant" }) {
                $results += [PSCustomObject]@{
                    DeviceName = $device.deviceName; PrimaryUser = $device.userPrincipalName
                    City = $u?.City ?? "Unknown"; Country = $u?.Country ?? "Unknown"; Department = $u?.Department ?? "Unknown"; AccountEnabled = $u?.AccountEnabled ?? "Unknown"
                    Manufacturer = $device.manufacturer; Model = $device.model; LenovoModelDetails = $lenovo
                    LastSyncDateTime = $device.lastSyncDateTime; CompliancePolicyName = $policyState.displayName
                    IsCompliant = $false
                    SettingName  = $setting.setting -replace '.*\.', ''   # strip the policy prefix -> friendly-ish name
                    SettingState = $setting.state
                    StateDetails = $setting.errorDescription
                }
            }
        }
    } catch {
        Write-Warning "Failed to process compliance policies for device $($device.deviceName): $_"
    }
}

# --- Step 8: Root CSV for Power BI ---
if ($results.Count -gt 0) {
    $outputPath = Join-Path -Path $ExportLocation -ChildPath $OutputFileName
    $results | Export-Csv -Path $outputPath -NoTypeInformation -Force
    try {
        Set-AzStorageBlobContent -File $outputPath -Container $Container -Blob $OutputFileName -Context $StorageAccountContext -Force | Out-Null
        Write-Verbose "Uploaded $Container/$OutputFileName"
    } catch { Write-Warning "Failed to upload root report: $_" }
} else { Write-Verbose "No results to export." }

# --- Step 9: Agent copies + DISTINCT-DEVICE stats (count devices, never rows) ---
if ($results.Count -gt 0) {
    $AgentRows = $results | Select-Object DeviceName,PrimaryUser,City,Country,Department,AccountEnabled,Manufacturer,Model,LenovoModelDetails,LastSyncDateTime,CompliancePolicyName,IsCompliant,SettingName,SettingState,StateDetails,@{Name='SnapshotDate';Expression={$Today}}

    $Stats = [System.Collections.Generic.List[object]]::new()
    function Add-NCStat { param([string]$Category,[string]$Key,[int]$Count)
        $Stats.Add([pscustomobject]@{ ReportType='NonCompliant'; Category=$Category; Key=$Key; Count=$Count; SnapshotDate=$Today }) }
    function Get-DistinctDeviceCount($Group) { @($Group | Select-Object -ExpandProperty DeviceName -Unique).Count }

    $TotalDevices = @($results | Select-Object -ExpandProperty DeviceName -Unique).Count
    Add-NCStat 'Total' 'NonCompliantDevicesReported' $DeviceCount
    Add-NCStat 'Total' 'NonCompliantDevices'         $TotalDevices
    Add-NCStat 'Total' 'DevicesWithNoFailingSetting' ($DeviceCount - $TotalDevices)
    Add-NCStat 'Total' 'FailingSettingRows'          $results.Count
    $results | Group-Object SettingName          | ForEach-Object { Add-NCStat 'NonCompliantBySetting'  $_.Name (Get-DistinctDeviceCount $_.Group) }
    $results | Group-Object CompliancePolicyName | ForEach-Object { Add-NCStat 'NonCompliantByPolicy'   $_.Name (Get-DistinctDeviceCount $_.Group) }
    $results | Group-Object { "$($_.CompliancePolicyName) | $($_.SettingName)" } | ForEach-Object { Add-NCStat 'NonCompliantByPolicyAndSetting' $_.Name (Get-DistinctDeviceCount $_.Group) }
    $results | Where-Object { $_.Country }      | Group-Object Country      | ForEach-Object { Add-NCStat 'NonCompliantByCountry'      $_.Name (Get-DistinctDeviceCount $_.Group) }
    $results | Where-Object { $_.Manufacturer } | Group-Object Manufacturer | ForEach-Object { Add-NCStat 'NonCompliantByManufacturer' $_.Name (Get-DistinctDeviceCount $_.Group) }

    if ($TotalDevices -ne $DeviceCount) {
        Write-Warning "Graph reported $DeviceCount non-compliant devices but only $TotalDevices produced setting rows. $($DeviceCount - $TotalDevices) returned no failing settings."
    }

    function Publish-AgentCsv { param($Data,[string]$Name)
        if (-not $Data) { return }
        $path = Join-Path $ExportLocation $Name
        $Data | Export-Csv -Path $path -NoTypeInformation -Encoding UTF8 -Force
        $mb = [math]::Round((Get-Item $path).Length / 1MB, 2)
        if ($mb -le 12) {
            Set-AzStorageBlobContent -File $path -Container $Container -Blob "agent-data/$Name" -Context $StorageAccountContext -Force | Out-Null
            Write-Output "$Name -> agent-data ($mb MB)"
        } else { Write-Warning "$Name is $mb MB - over the 12MB agent gate; skipped." }
    }
    Publish-AgentCsv -Data $AgentRows -Name $AgentFileName
    Publish-AgentCsv -Data $Stats     -Name $StatsFileName
}

# --- Step 10 (optional): copy the root CSV to a secondary share ---
if ($SecondarySharePath -and $results.Count -gt 0) {
    try {
        Copy-Item -Path $outputPath -Destination (Join-Path -Path $SecondarySharePath -ChildPath $OutputFileName) -Force
        Write-Verbose "Copied CSV to secondary share."
    } catch { Write-Warning "Failed to copy CSV to secondary share: $_" }
}

Write-Verbose "Script completed at $(Get-Date)"

View / download on GitHub

The report

The afternoon of per-device clicking becomes one page. Point Power BI at the CSV the runbook writes — no live tenant connection — and every question answers itself.

Power BI report: non-compliant Windows devices by failing setting, by country and by manufacturer, on the read-only snapshot (synthetic lab data)

The one clever bit is friendly setting names. Graph returns machine-readable values — ActiveFirewallRequired, RtpEnabled, OsMinimumVersion. The report maps them to what an admin actually sees in the portal — Firewall, Real-time protection, Minimum OS version — then de-dupes so each device × setting counts once. Now the questions answer themselves: which setting fails most, which countries and departments carry the most failing devices, and which manufacturers concentrate the risk.

The business value

The manual drill-down and the report answer the same question. The difference is what you can do with the answer:

  • Target the fix. "42 devices fail Firewall, mostly in two countries" is a work item. "We have some non-compliant devices" is not.
  • Trend it. Because it's a snapshot on a schedule, you can watch the number move after a remediation — proof the fix worked, not a vibe.
  • Hand it over. A filtered list per department goes to the person who can act, with the failing setting already named.
  • No standing access. The report is a sanitized CSV. Whoever reads it — an analyst, a dashboard — never holds a credential that can touch the fleet.

Set it up, step by step

You don't build this one from scratch. Every collector shares the same read-only plumbing, so you set that up once — after that, adding this report is about a five-minute job.

  1. One-time — stand up the collection layer. Follow Setting up the collection layer: an Azure Automation account, a system-assigned Managed Identity (no secrets, no app registration), and a storage account for the CSV snapshots. You only do this once, however many collectors you end up running.
  2. Grant this collector's read-only scopes. In that guide's role-assignment step, add the scopes this one needs — DeviceManagementManagedDevices.Read.All and User.Read.All. Every one ends in .Read.All: it reads, and never writes to your tenant. (Running more than one collector? Scopes are additive — add the new ones, don't replace what's already granted.)
  3. Import the script as a runbook. Take the script, import it into the Automation Account as a PowerShell 7 runbook, and publish it.
  4. Schedule it. Attach a daily (or weekly) schedule the same way the setup guide shows. It then runs unattended, dropping a dated CSV into your root/ container each time.
  5. Point Power BI at the CSV. Open the report template in Power BI Desktop and start with the bundled synthetic sample, so you can build the whole thing before touching real data. To switch to live data, use Get Data → Azure Blob Storage and point it at the dated CSV in your root/ container (the setup guide has the storage account and connection details). Refresh, and that's your dashboard.

No secrets, no app registration, nothing that can change your tenant — just a scheduled read and a CSV that Power BI draws from.

FAQ

Does this change anything in my tenant? No. Every Graph call is a GET, the identity is read-only, and the output is a file. It cannot remediate, and it cannot be used to remediate.

Why the /beta endpoint? Setting-level compliance detail is only exposed on /beta. Beta shapes can change — reproduce it in a lab before you depend on it, and expect the odd device that returns policy states but no setting states (the script handles that case).

Can I run it without Azure Automation? Yes — it's plain PowerShell 7. Automation + Managed Identity is just the cleanest way to run it on a schedule with no secrets.

More in this series

References — Microsoft documentation

The Microsoft Learn documentation behind this one, if you want to go to the source:


Screenshots use synthetic data from a personal lab — no real tenant, users, or devices. Independent content, not affiliated with, sponsored by, or endorsed by Microsoft. Microsoft, Intune, Entra, Microsoft Graph, Azure, Defender and Power BI are trademarks of the Microsoft group of companies.

← More from the blog