1. Azure Resource Setup
1.1 Create Storage Account
# 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
# 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:
# 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:
# 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
FunctionApp/
├── host.json
├── requirements.psd1
├── profile.ps1
└── HealthReportIngestion/
├── function.json # HTTP POST trigger binding
└── run.ps1 # Ingestion logic
2.2 function.json
{
"bindings": [
{
"authLevel": "anonymous",
"type": "httpTrigger",
"direction": "in",
"name": "Request",
"methods": ["post"]
},
{
"type": "http",
"direction": "out",
"name": "Response"
}
]
}
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.
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: $_"
})
}
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 → HighwindHealth_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 = "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: $_"}) }
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: $_"}) }
2.4 Data Destination Reference
The Function App upserts each report as a row with flat, typed columns for Power BI.
| Category | Columns |
|---|---|
| Identity | Hostname, OsVersion, OsBuild, OrganizationId, DeviceGroup, DomainName, IpAddress, LastBootTimeUtc |
| Enterprise Tags | DeviceTag, OrgUnit, Environment (from HKLM\SOFTWARE\Policies\Highwind) |
| Entra ID | IsAzureAdJoined, IsHybridJoined, AzureAdDeviceId, AzureAdTenantId, DeviceTrustType |
| Defender AV | DefenderEnabled, RealTimeProtectionEnabled, TamperProtectionEnabled, BehaviorMonitorEnabled, AntivirusEngineVersion, PlatformVersion, SignatureVersion, SignatureAgeHours, LastQuickScanUtc, LastFullScanUtc, DefenderAvStatus |
| MDE | IsOnboarded, SenseIsRunning, MdeOrgId, MdeStatus, ServiceVersion |
| ASR | AsrIsConfigured, AsrTotalRules, AsrBlockModeRules, AsrAuditModeRules, AsrStatus |
| Firewall | FirewallDomainEnabled, FirewallPrivateEnabled, FirewallPublicEnabled, FirewallStatus |
| Network / CFA / Device Control | NetworkProtectionMode, NetworkProtectionStatus, ControlledFolderAccessMode, ControlledFolderAccessStatus, DeviceControlEnabled, DeviceControlRuleCount, DeviceControlDefaultEnforcement, DeviceControlStatus |
| App Control (WDAC) | AppControlIsConfigured, AppControlIsEnabled, AppControlPolicyCount, AppControlStatus |
| SmartScreen / Exploit Protection | SmartScreenEdgeEnabled, SmartScreenExplorerMode, SmartScreenStatus, ExploitProtectionDepEnabled, ExploitProtectionAslrEnabled, ExploitProtectionCfgEnabled, ExploitProtectionStatus |
| Secure Boot / BitLocker / TPM | SecureBootEnabled, SecureBootIsUefi, SecureBootStatus, BitLockerOsDriveProtected, BitLockerStatus, TpmIsPresent, TpmIsEnabled, TpmVersion, TpmStatus |
| VBS / HVCI | VbsEnabled, VbsStatus, HvciConfigured, HvciRunning, CredentialGuardConfigured, CredentialGuardRunning |
| Windows Update | WindowsUpdateAutoEnabled, WindowsUpdateCurrentBuild, WindowsUpdateDaysSinceLastUpdate, WindowsUpdateStatus |
| PowerShell | PowerShellExecutionPolicy, PowerShellConstrainedLanguageMode, PowerShellScriptBlockLogging, PowerShellStatus |
| Remote Desktop / LAPS | RemoteDesktopEnabled, RemoteDesktopNlaRequired, RemoteDesktopStatus, LocalAdminCount, LapsConfigured, LapsEnabled, LocalAdminStatus |
| Health | HealthScore (0-100), OverallStatus, CriticalEventCount, TopIssues |
| Full Report | ReportJson: 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.
2.5 Deploy
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.
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.
# 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
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
| Setting | Value |
|---|---|
| Target resource | The App Registration (Highwind 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 |
Where to find it: Entra ID → Conditional Access → New Policy → Grant controls → Require compliant device
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
| 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 |