Step 1: Azure & Function App

Create Azure resources, deploy the Function App ingestion script, and secure it with Entra ID authentication.

1. Azure Resource Setup

1.1 Create Storage Account

Azure CLI
# 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

1.2 Create Function App

Azure CLI
# 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

1.3 Grant Table Storage Access

The Function App's Managed Identity needs Storage Table Data Contributor on the storage account:

Azure CLI
# 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

1.4 Register Entra ID App (Function App Audience)

Create an App Registration so endpoints can acquire a bearer token scoped to the Function App:

Azure CLI
# 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

2. Function App Deployment

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.

2.1 Project Structure

File Layout
FunctionApp/
├── host.json
├── requirements.psd1
├── profile.ps1
└── HealthReportIngestion/
    ├── function.json          # HTTP POST trigger binding
    └── run.ps1                # Ingestion logic

2.2 function.json

FunctionApp/HealthReportIngestion/function.json
{
  "bindings": [
    {
      "authLevel": "anonymous",
      "type": "httpTrigger",
      "direction": "in",
      "name": "Request",
      "methods": ["post"]
    },
    {
      "type": "http",
      "direction": "out",
      "name": "Response"
    }
  ]
}
Auth level is "anonymous" because authentication is handled by the Entra ID Identity Provider configured on the Function App (see Security section below), not by a function key.

2.3 Ingestion Scripts

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 ~100 typed columns for Power BI — including enterprise tags (DeviceTag, OrgUnit, Environment), Defender engine / platform / signature versions, scan times, MDE OrgId, App Control policy count, and PowerShell execution policy.

run.ps1 — Azure Table Storage
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: prefer flat root-level field, fall back to nested path
    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

        # Device identification
        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

        # Enterprise tagging (HKLM\SOFTWARE\Policies\MDE-Toolkit)
        DeviceTag = $report.deviceTag
        OrgUnit = $report.orgUnit
        Environment = Coalesce $report.environmentLabel $report.environment

        # Entra ID / Identity
        IsAzureAdJoined = CoalesceBool $report.isAzureAdJoined $null
        IsHybridJoined = CoalesceBool $report.isHybridJoined $null
        AzureAdDeviceId = $report.azureAdDeviceId
        AzureAdTenantId = $report.azureAdTenantId
        DeviceTrustType = $report.deviceTrustType

        # Overall health
        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 }

        # Defender AV
        DefenderEnabled = CoalesceBool $report.defenderEnabled $null
        RealTimeProtectionEnabled = CoalesceBool $report.realTimeProtectionEnabled $null
        BehaviorMonitorEnabled = CoalesceBool $report.behaviorMonitorEnabled $null
        DefenderAvStatus = CoalesceStr $report.defenderAvStatus $null "Unknown"
        AntivirusEngineVersion = $report.antivirusEngineVersion
        SignatureVersion = $report.signatureVersion
        PlatformVersion = $report.platformVersion
        SignatureAgeHours = CoalesceInt $report.signatureAgeHours $null
        LastQuickScanUtc = $report.lastQuickScanUtc
        LastFullScanUtc = $report.lastFullScanUtc

        # MDE / Endpoint
        IsOnboarded = CoalesceBool $report.isOnboarded $null
        SenseIsRunning = CoalesceBool $report.senseIsRunning $null
        MdeOrgId = $report.mdeOrgId
        TamperProtectionEnabled = CoalesceBool $report.tamperProtectionEnabled $null
        MdeStatus = CoalesceStr $report.mdeStatus $null "Unknown"

        # ASR
        AsrIsConfigured = CoalesceBool $report.asrIsConfigured $report.asr.isConfigured
        AsrTotalRules = CoalesceInt $report.asrTotalRules $report.asr.totalRules
        AsrBlockModeRules = CoalesceInt $report.asrBlockModeRules $report.asr.blockModeRules
        AsrAuditModeRules = CoalesceInt $report.asrAuditModeRules $report.asr.auditModeRules
        AsrStatus = CoalesceStr $report.asrStatus $report.asr.status "Unknown"

        # Firewall
        FirewallDomainEnabled = CoalesceBool $report.firewallDomainEnabled $null
        FirewallPrivateEnabled = CoalesceBool $report.firewallPrivateEnabled $null
        FirewallPublicEnabled = CoalesceBool $report.firewallPublicEnabled $null
        FirewallStatus = CoalesceStr $report.firewallStatus $null "Unknown"

        # Network Protection
        NetworkProtectionMode = CoalesceInt $report.networkProtectionMode $report.networkProtection.mode
        NetworkProtectionStatus = CoalesceStr $report.networkProtectionStatus $report.networkProtection.status "Unknown"

        # Controlled Folder Access
        ControlledFolderAccessMode = CoalesceInt $report.controlledFolderAccessMode $report.controlledFolderAccess.mode
        ControlledFolderAccessStatus = CoalesceStr $report.controlledFolderAccessStatus $report.controlledFolderAccess.status "Unknown"

        # Device Control
        DeviceControlEnabled = CoalesceBool $report.deviceControlEnabled $report.deviceControl.isEnabled
        DeviceControlStatus = CoalesceStr $report.deviceControlStatus $report.deviceControl.status "Unknown"
        DeviceControlDefaultEnforcement = Coalesce $report.deviceControlDefaultEnforcement $report.deviceControl.defaultEnforcement
        DeviceControlRuleCount = CoalesceInt $report.deviceControlRuleCount $(if ($report.deviceControl.rules) { $report.deviceControl.rules.Count } else { 0 })

        # App Control (WDAC)
        AppControlIsConfigured = CoalesceBool $report.appControlIsConfigured $report.appControl.isConfigured
        AppControlIsEnabled = CoalesceBool $report.appControlIsEnabled $report.appControl.isEnabled
        AppControlStatus = CoalesceStr $report.appControlStatus $report.appControl.status "Unknown"
        AppControlPolicyCount = CoalesceInt $report.appControlPolicyCount 0

        # SmartScreen
        SmartScreenExplorerMode = Coalesce $report.smartScreenExplorerMode $report.smartScreen.explorerMode
        SmartScreenEdgeEnabled = CoalesceBool $report.smartScreenEdgeEnabled $report.smartScreen.edgeEnabled
        SmartScreenStatus = CoalesceStr $report.smartScreenStatus $report.smartScreen.status "Unknown"

        # Exploit Protection
        ExploitProtectionDepEnabled = CoalesceBool $report.exploitProtectionDepEnabled $report.exploitProtection.depEnabled
        ExploitProtectionAslrEnabled = CoalesceBool $report.exploitProtectionAslrEnabled $report.exploitProtection.aslrEnabled
        ExploitProtectionCfgEnabled = CoalesceBool $report.exploitProtectionCfgEnabled $report.exploitProtection.cfgEnabled
        ExploitProtectionStatus = CoalesceStr $report.exploitProtectionStatus $report.exploitProtection.status "NotConfigured"

        # Secure Boot / BitLocker / TPM
        SecureBootEnabled = CoalesceBool $report.secureBootEnabled $report.secureBoot.isEnabled
        SecureBootIsUefi = CoalesceBool $report.secureBootIsUefi $report.secureBoot.isUefi
        SecureBootStatus = CoalesceStr $report.secureBootStatus $report.secureBoot.status "Unknown"
        BitLockerOsDriveProtected = CoalesceBool $report.bitLockerOsDriveProtected $report.bitLocker.osDriveProtected
        BitLockerStatus = CoalesceStr $report.bitLockerStatus $report.bitLocker.status "Unknown"
        TpmIsPresent = CoalesceBool $report.tpmIsPresent $report.tpm.isPresent
        TpmIsEnabled = CoalesceBool $report.tpmIsEnabled $report.tpm.isEnabled
        TpmVersion = Coalesce $report.tpmVersion $report.tpm.version
        TpmStatus = CoalesceStr $report.tpmStatus $report.tpm.status "Unknown"

        # VBS / HVCI / Credential Guard
        VbsEnabled = CoalesceBool $report.vbsEnabled $report.vbs.vbsEnabled
        HvciConfigured = CoalesceBool $report.hvciConfigured $report.vbs.hvciConfigured
        HvciRunning = CoalesceBool $report.hvciRunning $report.vbs.hvciRunning
        CredentialGuardConfigured = CoalesceBool $report.credentialGuardConfigured $report.vbs.credentialGuardConfigured
        CredentialGuardRunning = CoalesceBool $report.credentialGuardRunning $report.vbs.credentialGuardRunning
        VbsStatus = CoalesceStr $report.vbsStatus $report.vbs.status "Unknown"

        # Windows Update
        WindowsUpdateDaysSinceLastUpdate = CoalesceInt $report.windowsUpdateDaysSinceLastUpdate $report.windowsUpdate.daysSinceLastUpdate
        WindowsUpdateCurrentBuild = Coalesce $report.windowsUpdateCurrentBuild $report.windowsUpdate.currentBuild
        WindowsUpdateAutoEnabled = CoalesceBool $report.windowsUpdateAutoEnabled $report.windowsUpdate.autoUpdateEnabled
        WindowsUpdateStatus = CoalesceStr $report.windowsUpdateStatus $report.windowsUpdate.status "Unknown"

        # PowerShell
        PowerShellExecutionPolicy = Coalesce $report.powerShellExecutionPolicy $report.powerShell.executionPolicy
        PowerShellConstrainedLanguageMode = CoalesceBool $report.powerShellConstrainedLanguageMode $report.powerShell.constrainedLanguageMode
        PowerShellScriptBlockLogging = CoalesceBool $report.powerShellScriptBlockLogging $report.powerShell.scriptBlockLoggingEnabled
        PowerShellStatus = CoalesceStr $report.powerShellStatus $report.powerShell.status "Unknown"

        # Remote Desktop / Local Admin / LAPS
        RemoteDesktopEnabled = CoalesceBool $report.remoteDesktopEnabled $report.remoteDesktop.isEnabled
        RemoteDesktopNlaRequired = CoalesceBool $report.remoteDesktopNlaRequired $report.remoteDesktop.nlaRequired
        RemoteDesktopStatus = CoalesceStr $report.remoteDesktopStatus $report.remoteDesktop.status "Unknown"
        LocalAdminCount = CoalesceInt $report.localAdminCount $report.localAdmin.adminCount
        LapsConfigured = CoalesceBool $report.lapsConfigured $report.localAdmin.lapsConfigured
        LapsEnabled = CoalesceBool $report.lapsEnabled $report.localAdmin.lapsEnabled
        LocalAdminStatus = CoalesceStr $report.localAdminStatus $report.localAdmin.status "Unknown"

        # Client / Schema
        ServiceVersion = Coalesce $report.serviceVersion $report.collectorVersion
        SchemaVersion = CoalesceInt $report.schemaVersion $null

        ReportJson = $null   # slimmed below
    }

    # Slim ReportJson to fit Table Storage 64KB (32K UTF-16 char) per-property 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"                                     # OAuth resource is the SAME for all clouds
    $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 missing (ignore 409)
    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)
    Invoke-RestMethod -Uri "${base}/${tn}(PartitionKey='${pk}',RowKey='${rk}')" -Method Put -Headers $h -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: $_"
    })
}
⚠️ Not Tested. Review and test thoroughly before deploying.

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.

run.ps1 — Blob Storage (Not Tested)
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: $_"}) }
⚠️ Not Tested. Uses the legacy Data Collector API. For new workspaces consider DCR-based ingestion.

Sends health reports to Log Analytics → MDEToolkitHealth_CL.

Required: WorkspaceId, WorkspaceKey

run.ps1 — Sentinel / Log Analytics (Not Tested)
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: $_"}) }
⚠️ Not Tested. Requires a Dataverse table with matching columns and the Function App's MI granted Dataverse System User.

Upserts into Dataverse for Power Platform dashboards and Power Automate flows.

Required: DataverseUrl, DataverseTableName

run.ps1 — Dataverse (Not Tested)
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: $_"}) }

2.4 Data Destination Reference

The Function App upserts each report as a row with flat, typed columns for Power BI.

CategoryColumns
IdentityHostname, OsVersion, OsBuild, OrganizationId, DeviceGroup, DomainName, IpAddress, LastBootTimeUtc
Enterprise TagsDeviceTag, OrgUnit, Environment (from HKLM\SOFTWARE\Policies\MDE-Toolkit)
Entra IDIsAzureAdJoined, IsHybridJoined, AzureAdDeviceId, AzureAdTenantId, DeviceTrustType
Defender AVDefenderEnabled, RealTimeProtectionEnabled, TamperProtectionEnabled, BehaviorMonitorEnabled, AntivirusEngineVersion, PlatformVersion, SignatureVersion, SignatureAgeHours, LastQuickScanUtc, LastFullScanUtc, DefenderAvStatus
MDEIsOnboarded, SenseIsRunning, MdeOrgId, MdeStatus, ServiceVersion
ASRAsrIsConfigured, AsrTotalRules, AsrBlockModeRules, AsrAuditModeRules, AsrStatus
FirewallFirewallDomainEnabled, FirewallPrivateEnabled, FirewallPublicEnabled, FirewallStatus
Network / CFA / Device ControlNetworkProtectionMode, NetworkProtectionStatus, ControlledFolderAccessMode, ControlledFolderAccessStatus, DeviceControlEnabled, DeviceControlRuleCount, DeviceControlDefaultEnforcement, DeviceControlStatus
App Control (WDAC)AppControlIsConfigured, AppControlIsEnabled, AppControlPolicyCount, AppControlStatus
SmartScreen / Exploit ProtectionSmartScreenEdgeEnabled, SmartScreenExplorerMode, SmartScreenStatus, ExploitProtectionDepEnabled, ExploitProtectionAslrEnabled, ExploitProtectionCfgEnabled, ExploitProtectionStatus
Secure Boot / BitLocker / TPMSecureBootEnabled, SecureBootIsUefi, SecureBootStatus, BitLockerOsDriveProtected, BitLockerStatus, TpmIsPresent, TpmIsEnabled, TpmVersion, TpmStatus
VBS / HVCIVbsEnabled, VbsStatus, HvciConfigured, HvciRunning, CredentialGuardConfigured, CredentialGuardRunning
Windows UpdateWindowsUpdateAutoEnabled, WindowsUpdateCurrentBuild, WindowsUpdateDaysSinceLastUpdate, WindowsUpdateStatus
PowerShellPowerShellExecutionPolicy, PowerShellConstrainedLanguageMode, PowerShellScriptBlockLogging, PowerShellStatus
Remote Desktop / LAPSRemoteDesktopEnabled, RemoteDesktopNlaRequired, RemoteDesktopStatus, LocalAdminCount, LapsConfigured, LapsEnabled, LocalAdminStatus
HealthHealthScore (0-100), OverallStatus, CriticalEventCount, TopIssues
Full ReportReportJson — complete snapshot (bulky sections stripped, hard-truncated at 30 KB)

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.

Coming Soon. Dataverse integration is planned for a future release.
Coming Soon. Sentinel / Log Analytics integration is planned for a future release.

2.5 Deploy

Azure CLI
cd FunctionApp
func azure functionapp publish func-mde-toolkit --powershell

2.6 Government Cloud

Set AzureEnvironment=Government in app settings. The script auto-detects core.usgovcloudapi.net.

3. Securing the Function App

The Function App endpoint is open to the internet by default. Use the layers below to restrict who can upload data.

3.1 Enable Entra ID Identity Provider

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.

Azure CLI — Enable Entra ID authentication
# 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

How it works. When you enable the Identity Provider, the Azure App Service authentication middleware (formerly "EasyAuth") validates every incoming request's 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.

3.2 Conditional Access — Restrict by Device State

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.

Recommended Policy

SettingValue
Target resourceThe App Registration (MDE Toolkit Function)
UsersAll users (or a specific group)
Conditions → Device platformsWindows only
GrantRequire 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

Effect: A non-compliant or non-joined device will fail to acquire a token, so the upload task exits gracefully with an auth error. Collection still succeeds (runs as SYSTEM, no token needed).

3.3 Network Restrictions (Optional)

For additional defense-in-depth, restrict network access to the Function App.

IP Allow List

In Function App → Networking → Access Restrictions, add the public IP ranges of your corporate network or VPN exit points.

📸

Screenshot: Networking → Access Restrictions

Private Endpoint

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

3.4 Security Checklist

LayerWhat It DoesRequired?
Identity Provider (Entra ID)Validates bearer token on every request — rejects anonymous calls✅ Yes
Conditional AccessRestricts 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 RestrictionsOnly allows requests from known corporate IP rangesOptional
Private EndpointFunction App only reachable from VNet — no public IPOptional (zero-trust)
Function App KeysNot used — auth is handled by Identity ProviderN/A