Create Azure resources, deploy the Function App ingestion script, and secure it with Entra ID authentication.
# Create resource group
az group create --name rg-mde-toolkit --location eastus
# Create storage account
az storage account create \
--name stmdetoolkit \
--resource-group rg-mde-toolkit \
--sku Standard_LRS \
--kind StorageV2
# Create the table (optional — the Function App auto-creates it)
az storage table create \
--name HealthReports \
--account-name stmdetoolkit
# Create Function App (PowerShell 7.4 runtime)
az functionapp create \
--name func-mde-toolkit \
--resource-group rg-mde-toolkit \
--storage-account stmdetoolkit \
--consumption-plan-location eastus \
--runtime powershell \
--runtime-version 7.4 \
--functions-version 4 \
--os-type Windows
# Enable system-assigned Managed Identity
az functionapp identity assign \
--name func-mde-toolkit \
--resource-group rg-mde-toolkit
# Set app settings
az functionapp config appsettings set \
--name func-mde-toolkit \
--resource-group rg-mde-toolkit \
--settings \
StorageAccountName=stmdetoolkit \
StorageTableName=HealthReports \
AzureEnvironment=Public
The Function App's Managed Identity needs Storage Table Data Contributor on the storage account:
# Get the Function App's principal ID
PRINCIPAL_ID=$(az functionapp identity show \
--name func-mde-toolkit \
--resource-group rg-mde-toolkit \
--query principalId -o tsv)
# Get the storage account resource ID
STORAGE_ID=$(az storage account show \
--name stmdetoolkit \
--resource-group rg-mde-toolkit \
--query id -o tsv)
# Assign Storage Table Data Contributor
az role assignment create \
--assignee $PRINCIPAL_ID \
--role "Storage Table Data Contributor" \
--scope $STORAGE_ID
Create an App Registration so endpoints can acquire a bearer token scoped to the Function App:
# Create app registration
az ad app create \
--display-name "MDE Toolkit Function" \
--identifier-uris "api://mde-toolkit-func" \
--sign-in-audience AzureADMyOrg
# Note the Application (client) ID — this becomes the FunctionAppAudience
# registry value: api://mde-toolkit-func
The Function App is a single PowerShell HTTP trigger that receives the JSON snapshot from each endpoint, flattens it, and writes it to the configured backend.
FunctionApp/
├── host.json
├── requirements.psd1
├── profile.ps1
└── HealthReportIngestion/
├── function.json # HTTP POST trigger binding
└── run.ps1 # Ingestion logic
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "Request",
"methods": ["post"]
},
{
"type": "http",
"direction": "out",
"name": "Response"
}
]
}
Each destination has its own run.ps1. Click the tabs below to view the script for your target backend.
The default and production-tested destination. Upserts a flat entity into Azure Table Storage with ~80 typed columns for Power BI.
using namespace System.Net
param($Request, $TriggerMetadata)
Write-Host "Health report ingestion request received"
try {
$requestBody = $Request.Body
if (-not $requestBody) {
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::BadRequest
Body = "Request body is empty"
})
return
}
if ($requestBody -is [string]) {
$report = $requestBody | ConvertFrom-Json -Depth 20
$rawJson = $requestBody
} else {
$report = $requestBody
$rawJson = $report | ConvertTo-Json -Depth 20 -Compress
}
# Helpers
function Coalesce { param($a, $b) if ($null -ne $a -and $a -ne '') { $a } else { $b } }
function CoalesceBool { param($a, $b) if ($a) { [bool]$a } elseif ($b) { [bool]$b } else { $false } }
function CoalesceInt { param($a, $b) if ($a) { [int]$a } elseif ($b) { [int]$b } else { 0 } }
function CoalesceStr { param($a, $b, $d) $v = Coalesce $a $b; if ($v) { [string]$v } else { $d } }
# Device info from headers or report body
$hostname = if ($Request.Headers["X-Device-Hostname"]) { $Request.Headers["X-Device-Hostname"] }
elseif ($report.hostname) { $report.hostname }
elseif ($report.machineName) { $report.machineName } else { "unknown" }
$organizationId = if ($Request.Headers["X-Organization-Id"]) { $Request.Headers["X-Organization-Id"] }
elseif ($report.organizationId) { $report.organizationId } else { "default" }
$deviceGroup = if ($Request.Headers["X-Device-Group"]) { $Request.Headers["X-Device-Group"] }
else { $report.deviceGroup }
$reportId = if ($report.reportId) { $report.reportId } else { [Guid]::NewGuid().ToString() }
$generatedAt = if ($report.generatedAtUtc) {
if ($report.generatedAtUtc -is [datetime]) { $report.generatedAtUtc.ToUniversalTime().ToString("o") }
else { [string]$report.generatedAtUtc }
} elseif ($report.collectedUtc) {
if ($report.collectedUtc -is [datetime]) { $report.collectedUtc.ToUniversalTime().ToString("o") }
else { [string]$report.collectedUtc }
} else { (Get-Date).ToUniversalTime().ToString("o") }
$uploadedAt = (Get-Date).ToUniversalTime().ToString("o")
# Build flat entity for Table Storage / Power BI
$entity = @{
PartitionKey = $organizationId
RowKey = "${hostname}_${reportId}"
GeneratedAtUtc = $generatedAt; UploadedAtUtc = $uploadedAt
ReportId = $reportId; Hostname = $hostname; DeviceGroup = $deviceGroup
OrganizationId = $organizationId
OsVersion = Coalesce $report.osVersion $report.osBuild
OsBuild = $report.osBuild; DomainName = $report.domainName
IpAddress = $report.ipAddress; LastBootTimeUtc = $report.lastBootTimeUtc
IsAzureAdJoined = CoalesceBool $report.isAzureAdJoined $null
IsHybridJoined = CoalesceBool $report.isHybridJoined $null
AzureAdDeviceId = $report.azureAdDeviceId
AzureAdTenantId = $report.azureAdTenantId; DeviceTrustType = $report.deviceTrustType
HealthScore = CoalesceInt $report.healthScore $null
OverallStatus = CoalesceStr $report.overallStatus $null "Unknown"
CriticalEventCount = CoalesceInt $report.criticalEventCount $null
TopIssues = if ($report.topIssues) { $report.topIssues -join "; " } else { $null }
DefenderEnabled = CoalesceBool $report.defenderEnabled $null
RealTimeProtectionEnabled = CoalesceBool $report.realTimeProtectionEnabled $null
BehaviorMonitorEnabled = CoalesceBool $report.behaviorMonitorEnabled $null
DefenderAvStatus = CoalesceStr $report.defenderAvStatus $null "Unknown"
SignatureAgeHours = CoalesceInt $report.signatureAgeHours $null
IsOnboarded = CoalesceBool $report.isOnboarded $null
SenseIsRunning = CoalesceBool $report.senseIsRunning $null
TamperProtectionEnabled = CoalesceBool $report.tamperProtectionEnabled $null
MdeStatus = CoalesceStr $report.mdeStatus $null "Unknown"
AsrIsConfigured = CoalesceBool $report.asrIsConfigured $report.asr.isConfigured
AsrTotalRules = CoalesceInt $report.asrTotalRules $report.asr.totalRules
AsrBlockModeRules = CoalesceInt $report.asrBlockModeRules $report.asr.blockModeRules
AsrStatus = CoalesceStr $report.asrStatus $report.asr.status "Unknown"
FirewallDomainEnabled = CoalesceBool $report.firewallDomainEnabled $null
FirewallPrivateEnabled = CoalesceBool $report.firewallPrivateEnabled $null
FirewallPublicEnabled = CoalesceBool $report.firewallPublicEnabled $null
FirewallStatus = CoalesceStr $report.firewallStatus $null "Unknown"
DeviceControlEnabled = CoalesceBool $report.deviceControlEnabled $report.deviceControl.isEnabled
DeviceControlStatus = CoalesceStr $report.deviceControlStatus $report.deviceControl.status "Unknown"
DeviceControlRuleCount = CoalesceInt $report.deviceControlRuleCount 0
AppControlIsConfigured = CoalesceBool $report.appControlIsConfigured $report.appControl.isConfigured
AppControlIsEnabled = CoalesceBool $report.appControlIsEnabled $report.appControl.isEnabled
AppControlStatus = CoalesceStr $report.appControlStatus $report.appControl.status "Unknown"
VbsEnabled = CoalesceBool $report.vbsEnabled $report.vbs.vbsEnabled
HvciRunning = CoalesceBool $report.hvciRunning $report.vbs.hvciRunning
CredentialGuardRunning = CoalesceBool $report.credentialGuardRunning $report.vbs.credentialGuardRunning
VbsStatus = CoalesceStr $report.vbsStatus $report.vbs.status "Unknown"
SchemaVersion = CoalesceInt $report.schemaVersion $null
ServiceVersion = Coalesce $report.collectorVersion $report.clientConfig.serviceVersion
ReportJson = $null
}
# Slim ReportJson to fit Table Storage 64KB limit
try {
$slim = $report.PSObject.Copy()
foreach ($p in @('defenderPolicies','firewallProfiles','deviceGuardStatus')) {
if ($slim.PSObject.Properties[$p]) { $slim.PSObject.Properties.Remove($p) }
}
$j = $slim | ConvertTo-Json -Depth 10 -Compress
if ($j.Length -gt 30000) { $j = $j.Substring(0, 30000) + '..."TRUNCATED"}' }
$entity.ReportJson = $j
} catch { $entity.ReportJson = '{"error":"JSON too large"}' }
# Storage config
$sa = $env:StorageAccountName
$tn = if ($env:StorageTableName) { $env:StorageTableName } else { "HealthReports" }
$ae = if ($env:AzureEnvironment) { $env:AzureEnvironment } else { "Public" }
$res = "https://storage.azure.com"
$sfx = switch ($ae) { "Government" { "core.usgovcloudapi.net" }; "China" { "core.chinacloudapi.cn" }; default { "core.windows.net" } }
$base = "https://${sa}.table.${sfx}"
# Managed Identity token
$tok = (Invoke-RestMethod -Uri "$($env:IDENTITY_ENDPOINT)?resource=${res}&api-version=2019-08-01" `
-Headers @{"X-IDENTITY-HEADER"=$env:IDENTITY_HEADER} -Method Get).access_token
$h = @{ Authorization="Bearer $tok"; "Content-Type"="application/json"; Accept="application/json;odata=nometadata"; "x-ms-version"="2020-12-06"; "x-ms-date"=[DateTime]::UtcNow.ToString("R") }
# Create table if needed
try { Invoke-RestMethod -Uri "${base}/Tables" -Method Post -Headers $h -Body (@{TableName=$tn}|ConvertTo-Json) } catch {}
# Build OData entity with type annotations
$oe = @{ PartitionKey=$entity.PartitionKey; RowKey=$entity.RowKey }
foreach ($k in $entity.Keys) {
if ($k -ne "PartitionKey" -and $k -ne "RowKey" -and $null -ne $entity[$k]) {
$v = $entity[$k]
if ($v -is [bool]) { $oe[$k]=$v; $oe["$k@odata.type"]="Edm.Boolean" }
elseif ($v -is [int]) { $oe[$k]=$v; $oe["$k@odata.type"]="Edm.Int32" }
else { $oe[$k]=[string]$v }
}
}
# Upsert
$pk = [System.Web.HttpUtility]::UrlEncode($entity.PartitionKey)
$rk = [System.Web.HttpUtility]::UrlEncode($entity.RowKey)
$h2 = @{ Authorization="Bearer $tok"; "Content-Type"="application/json"; Accept="application/json;odata=nometadata"; "x-ms-version"="2020-12-06"; "x-ms-date"=[DateTime]::UtcNow.ToString("R") }
Invoke-RestMethod -Uri "${base}/${tn}(PartitionKey='${pk}',RowKey='${rk}')" -Method Put -Headers $h2 -Body ($oe|ConvertTo-Json -Depth 10 -Compress)
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode = [HttpStatusCode]::OK
Headers = @{"Content-Type"="application/json"}
Body = (@{ success=$true; reportId=$reportId; hostname=$hostname; healthScore=$entity.HealthScore } | ConvertTo-Json)
})
} catch {
Write-Error "Error: $_"
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{
StatusCode=[HttpStatusCode]::InternalServerError; Body="Error: $_"
})
}
Writes the full JSON snapshot as a blob. Useful for Azure Data Factory, Logic Apps, or custom pipelines.
Required: StorageAccountName, BlobContainerName, Storage Blob Data Contributor role.
using namespace System.Net
param($Request, $TriggerMetadata)
try {
$body = $Request.Body
if (-not $body) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::BadRequest;Body="Empty"}); return }
if ($body -is [string]) { $report=$body|ConvertFrom-Json -Depth 20; $raw=$body } else { $report=$body; $raw=$body|ConvertTo-Json -Depth 20 -Compress }
$host_ = if ($Request.Headers["X-Device-Hostname"]) {$Request.Headers["X-Device-Hostname"]} elseif ($report.hostname) {$report.hostname} else {"unknown"}
$rid = if ($report.reportId) {$report.reportId} else {[Guid]::NewGuid().ToString()}
$ts = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmss")
$sa = $env:StorageAccountName; $cn = if ($env:BlobContainerName) {$env:BlobContainerName} else {"mde-telemetry"}
$ae = if ($env:AzureEnvironment) {$env:AzureEnvironment} else {"Public"}
$sfx = switch ($ae) {"Government"{"blob.core.usgovcloudapi.net"};"China"{"blob.core.chinacloudapi.cn"};default{"blob.core.windows.net"}}
$tok = (Invoke-RestMethod -Uri "$($env:IDENTITY_ENDPOINT)?resource=https://storage.azure.com&api-version=2019-08-01" -Headers @{"X-IDENTITY-HEADER"=$env:IDENTITY_HEADER} -Method Get).access_token
$bn = "${host_}/${ts}_${rid}.json"
Invoke-RestMethod -Uri "https://${sa}.${sfx}/${cn}/${bn}" -Method Put -Headers @{Authorization="Bearer $tok";"x-ms-blob-type"="BlockBlob";"Content-Type"="application/json";"x-ms-version"="2020-12-06";"x-ms-date"=[DateTime]::UtcNow.ToString("R")} -Body $raw
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::OK;Headers=@{"Content-Type"="application/json"};Body=(@{success=$true;blobName=$bn;hostname=$host_}|ConvertTo-Json)})
} catch { Write-Error "Error: $_"; Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="Error: $_"}) }
Sends health reports to Log Analytics → MDEToolkitHealth_CL.
Required: WorkspaceId, WorkspaceKey
using namespace System.Net
param($Request, $TriggerMetadata)
try {
$body = $Request.Body
if (-not $body) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::BadRequest;Body="Empty"}); return }
if ($body -is [string]) { $report=$body|ConvertFrom-Json -Depth 20; $raw=$body } else { $report=$body; $raw=$body|ConvertTo-Json -Depth 20 -Compress }
$wid = $env:WorkspaceId; $wkey = $env:WorkspaceKey; $logType = "MDEToolkitHealth"
if (-not $wid -or -not $wkey) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="WorkspaceId/Key missing"}); return }
$date = [DateTime]::UtcNow.ToString("r"); $len = [System.Text.Encoding]::UTF8.GetByteCount($raw)
$str = "POST`n${len}`napplication/json`nx-ms-date:${date}`n/api/logs"
$hmac = New-Object System.Security.Cryptography.HMACSHA256; $hmac.Key = [Convert]::FromBase64String($wkey)
$sig = [Convert]::ToBase64String($hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($str)))
$auth = "SharedKey ${wid}:${sig}"
$resp = Invoke-WebRequest -Uri "https://${wid}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01" `
-Method Post -Headers @{Authorization=$auth;"Content-Type"="application/json";"Log-Type"=$logType;"x-ms-date"=$date;"time-generated-field"="generatedAtUtc"} -Body $raw -UseBasicParsing
$hn = if ($report.hostname) {$report.hostname} else {"unknown"}
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::OK;Headers=@{"Content-Type"="application/json"};Body=(@{success=$true;destination="loganalytics";logType="${logType}_CL";hostname=$hn}|ConvertTo-Json)})
} catch { Write-Error "Error: $_"; Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="Error: $_"}) }
Upserts into Dataverse for Power Platform dashboards and Power Automate flows.
Required: DataverseUrl, DataverseTableName
using namespace System.Net
param($Request, $TriggerMetadata)
try {
$body = $Request.Body
if (-not $body) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::BadRequest;Body="Empty"}); return }
if ($body -is [string]) { $report=$body|ConvertFrom-Json -Depth 20 } else { $report=$body }
function Coalesce { param($a,$b) if ($null -ne $a -and $a -ne '') {$a} else {$b} }
function CoalesceBool { param($a,$b) if ($a) {[bool]$a} elseif ($b) {[bool]$b} else {$false} }
function CoalesceInt { param($a,$b) if ($a) {[int]$a} elseif ($b) {[int]$b} else {0} }
$dvUrl = $env:DataverseUrl; $tn = if ($env:DataverseTableName) {$env:DataverseTableName} else {"mde_healthreports"}
if (-not $dvUrl) { Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="DataverseUrl missing"}); return }
$hn = if ($Request.Headers["X-Device-Hostname"]) {$Request.Headers["X-Device-Hostname"]} elseif ($report.hostname) {$report.hostname} else {"unknown"}
$rid = if ($report.reportId) {$report.reportId} else {[Guid]::NewGuid().ToString()}
$dvr = $dvUrl.TrimEnd('/')
$tok = (Invoke-RestMethod -Uri "$($env:IDENTITY_ENDPOINT)?resource=${dvr}&api-version=2019-08-01" -Headers @{"X-IDENTITY-HEADER"=$env:IDENTITY_HEADER} -Method Get).access_token
$ent = @{
mde_name="${hn}_${rid}"; mde_hostname=$hn; mde_reportid=$rid
mde_healthscore=CoalesceInt $report.healthScore $null
mde_overallstatus=if($report.overallStatus){[string]$report.overallStatus}else{"Unknown"}
mde_isonboarded=CoalesceBool $report.isOnboarded $null
mde_generatedat=(Get-Date).ToUniversalTime().ToString("o")
mde_reportjson=($report|ConvertTo-Json -Depth 10 -Compress)
}
Invoke-RestMethod -Uri "${dvr}/api/data/v9.2/${tn}(mde_name='$($ent.mde_name)')" -Method Patch `
-Headers @{Authorization="Bearer $tok";"Content-Type"="application/json";"OData-MaxVersion"="4.0";"OData-Version"="4.0";"If-Match"="*";"Prefer"="return=representation"} `
-Body ($ent|ConvertTo-Json -Depth 5 -Compress)
Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::OK;Headers=@{"Content-Type"="application/json"};Body=(@{success=$true;destination="dataverse";hostname=$hn}|ConvertTo-Json)})
} catch { Write-Error "Error: $_"; Push-OutputBinding -Name Response -Value ([HttpResponseContext]@{StatusCode=[HttpStatusCode]::InternalServerError;Body="Error: $_"}) }
The Function App upserts each report as a row with flat, typed columns for Power BI.
| Category | Columns |
|---|---|
| Identity | Hostname, OsVersion, OrganizationId, DeviceGroup |
| Defender AV | DefenderEnabled, RealTimeProtectionEnabled, TamperProtectionEnabled, SignatureAgeHours |
| MDE | IsOnboarded, SenseIsRunning, MdeStatus |
| ASR | AsrIsConfigured, AsrTotalRules, AsrBlockModeRules |
| Firewall | FirewallDomainEnabled, FirewallPrivateEnabled, FirewallPublicEnabled |
| VBS / HVCI | VbsEnabled, HvciRunning, CredentialGuardRunning |
| Health | HealthScore (0-100), OverallStatus, CriticalEventCount |
| Full Report | ReportJson — complete snapshot for drill-down |
Direct blob upload writes full JSON as {hostname}/{timestamp}.json. Useful for Data Factory or Logic Apps pipelines.
Required Role: Storage Blob Data Contributor on the container.
cd FunctionApp
func azure functionapp publish func-mde-toolkit --powershell
Set AzureEnvironment=Government in app settings. The script auto-detects core.usgovcloudapi.net.
The Function App endpoint is open to the internet by default. Use the layers below to restrict who can upload data.
Configure the Function App to require a valid Entra ID bearer token on every request. This is the primary authentication layer — without a valid token, the request gets a 401.
# Get the App Registration's client ID (from step 1.4)
APP_ID=$(az ad app list \
--display-name "MDE Toolkit Function" \
--query "[0].appId" -o tsv)
# Enable Entra ID authentication on the Function App
az webapp auth update \
--name func-mde-toolkit \
--resource-group rg-mde-toolkit \
--enabled true \
--action LoginWithAzureActiveDirectory \
--aad-client-id $APP_ID \
--aad-token-issuer-url "https://login.microsoftonline.com/YOUR-TENANT-ID/v2.0"
Screenshot: Azure Portal → Function App → Authentication → Add Identity Provider → Microsoft Entra ID
Authorization: Bearer header. The MDE Toolkit client acquires this token using the logged-on user's PRT via DefaultAzureCredential, scoped to api://mde-toolkit-func.
Use Entra ID Conditional Access to control which devices can acquire a token for the Function App. This lets you restrict uploads to only compliant, Entra-joined, or hybrid-joined machines.
| Setting | Value |
|---|---|
| Target resource | The App Registration (MDE Toolkit Function) |
| Users | All users (or a specific group) |
| Conditions → Device platforms | Windows only |
| Grant | Require device to be marked as compliant OR Require Hybrid Azure AD joined device |
Screenshot: Entra ID → Conditional Access → New Policy → Grant controls → Require compliant device
For additional defense-in-depth, restrict network access to the Function App.
In Function App → Networking → Access Restrictions, add the public IP ranges of your corporate network or VPN exit points.
Screenshot: Networking → Access Restrictions
For zero-trust environments, deploy the Function App with a Private Endpoint so it's only reachable from your VNet. Endpoints connect via VPN or ExpressRoute.
Screenshot: Networking → Private Endpoints
| Layer | What It Does | Required? |
|---|---|---|
| Identity Provider (Entra ID) | Validates bearer token on every request — rejects anonymous calls | ✅ Yes |
| Conditional Access | Restricts token issuance to compliant / joined devices | ✅ Recommended |
| Managed Identity (Function → Storage) | No storage keys — Function App uses MI to write to Table/Blob | ✅ Yes |
| IP Access Restrictions | Only allows requests from known corporate IP ranges | Optional |
| Private Endpoint | Function App only reachable from VNet — no public IP | Optional (zero-trust) |
| Function App Keys | Not used — auth is handled by Identity Provider | N/A |