Azure deployment

Create the storage account, Function App and Entra ID app registration that receive fleet reports, then restrict who can reach them.

1. Azure Resource Setup

1.1 Create Storage Account

Azure CLI
# Create resource group
az group create --name rg-highwind --location eastus

# Create storage account
az storage account create \
  --name sthighwind \
  --resource-group rg-highwind \
  --sku Standard_LRS \
  --kind StorageV2

# Create the table (optional — the Function App auto-creates it)
az storage table create \
  --name HealthReports \
  --account-name sthighwind

1.2 Create Function App

Azure CLI
# Create Function App (PowerShell 7.4 runtime)
az functionapp create \
  --name func-highwind \
  --resource-group rg-highwind \
  --storage-account sthighwind \
  --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-highwind \
  --resource-group rg-highwind

# Set app settings
az functionapp config appsettings set \
  --name func-highwind \
  --resource-group rg-highwind \
  --settings \
    StorageAccountName=sthighwind \
    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-highwind \
  --resource-group rg-highwind \
  --query principalId -o tsv)

# Get the storage account resource ID
STORAGE_ID=$(az storage account show \
  --name sthighwind \
  --resource-group rg-highwind \
  --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 "Highwind Function" \
  --identifier-uris "api://highwind-func" \
  --sign-in-audience AzureADMyOrg

# Note the Application (client) ID — this becomes the FunctionAppAudience
# registry value: api://highwind-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.
Section 3.1 is not optional. The ingestion script refuses any request that arrives without a validated X-MS-CLIENT-PRINCIPAL header, which only the Entra ID Identity Provider can produce. If you skip that step, every upload returns 401. This is deliberate: earlier versions of the script accepted unauthenticated writes when the identity provider was missing, and derived the storage partition from a client-supplied header.

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" }

    # The partition comes from the VALIDATED token injected by Easy Auth (X-MS-CLIENT-PRINCIPAL),
    # never from a client-supplied header. See the Security section: a request with no validated
    # principal is refused with 401 before any of this runs.
    $organizationId = $callerTenantId
    $claimedOrganizationId = if ($Request.Headers["X-Organization-Id"]) { $Request.Headers["X-Organization-Id"] }
                             elseif ($report.organizationId) { $report.organizationId } else { $null }

    $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\Highwind)
        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 → HighwindHealth_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 = "HighwindHealth"
    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\Highwind)
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-highwind --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.

Required, and enforced by the script. The identity provider validates the token and injects the caller's claims as X-MS-CLIENT-PRINCIPAL. The ingestion script reads the tenant from those validated claims and uses it as the Table Storage PartitionKey, so a device can only ever write into its own tenant's partition. With no identity provider configured, no principal header arrives and the script returns 401 rather than storing the report.

Optionally set an AllowedTenantIds app setting (comma-separated) to restrict which tenants may upload. If unset, any tenant holding a valid token for this audience is accepted; still isolated, since each writes only to its own partition, but an estate that knows its tenant should say so.

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 "Highwind Function" \
  --query "[0].appId" -o tsv)

# Enable Entra ID authentication on the Function App
az webapp auth update \
  --name func-highwind \
  --resource-group rg-highwind \
  --enabled true \
  --action LoginWithAzureActiveDirectory \
  --aad-client-id $APP_ID \
  --aad-token-issuer-url "https://login.microsoftonline.com/YOUR-TENANT-ID/v2.0"

Where to find it: 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 Highwind client acquires this token using the logged-on user's PRT via DefaultAzureCredential, scoped to api://highwind-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 (Highwind 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

Where to find it: 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.

Where to find it: 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.

Where to find it: Networking → Private Endpoints

3.4 Security Checklist

LayerWhat It DoesRequired?
Identity Provider (Entra ID)Validates bearer token on every request; rejects anonymous callsYes
Conditional AccessRestricts token issuance to compliant / joined devicesRecommended
Managed Identity (Function → Storage)No storage keys; Function App uses MI to write to Table/BlobYes
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