Modules/Outputs/Publishers/10-CloudPublisher.ps1
|
#Requires -Version 7.0 <# .SYNOPSIS v2.3.0 cloud publishing — Azure Blob publisher (#244), catalog + latest-pointer blobs (#245), and Log Analytics Workspace sink (#247). Keeps every Azure SDK call behind a small number of testable helpers so fixture-mode and unit tests don't require real Azure credentials. #> function Resolve-RangerRemoteStorageConfig { <# .SYNOPSIS Pull the v2.3.0 remote-storage config block out of a Ranger config with sane defaults. .DESCRIPTION Accepts the fully-resolved config hashtable (as produced by ConvertTo-RangerHashtable on the config load path) and returns either a normalised remote-storage block or $null when blob publishing is disabled. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config ) $rs = $null if ($Config.output -and $Config.output.remoteStorage) { $rs = $Config.output.remoteStorage } if (-not $rs) { return $null } $type = [string]$rs.type if ([string]::IsNullOrWhiteSpace($type) -or $type -eq 'none') { return $null } return [ordered]@{ type = $type storageAccount = [string]$rs.storageAccount container = [string]$rs.container pathTemplate = if (-not [string]::IsNullOrWhiteSpace([string]$rs.pathTemplate)) { [string]$rs.pathTemplate } else { '{cluster}/{yyyy-MM-dd}/{runId}' } include = if ($rs.include) { @($rs.include | ForEach-Object { [string]$_ }) } else { @('manifest','evidence','packageIndex','runLog') } authMethod = if (-not [string]::IsNullOrWhiteSpace([string]$rs.authMethod)) { [string]$rs.authMethod } else { 'default' } sasRef = [string]$rs.sasRef blobTags = if ($rs.blobTags) { $rs.blobTags } else { @{} } writeHistory = [bool]$rs.writeHistory failRunOnPublishError = [bool]$rs.failRunOnPublishError } } function Resolve-RangerLogAnalyticsConfig { <# .SYNOPSIS v2.3.0 (#247): Log Analytics sink config resolver. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Config ) $la = $null if ($Config.output -and $Config.output.logAnalytics) { $la = $Config.output.logAnalytics } if (-not $la) { return $null } if (-not $la.enabled) { return $null } return [ordered]@{ enabled = [bool]$la.enabled dataCollectionEndpoint = [string]$la.dataCollectionEndpoint dataCollectionRuleImmutableId = [string]$la.dataCollectionRuleImmutableId streamName = if (-not [string]::IsNullOrWhiteSpace([string]$la.streamName)) { [string]$la.streamName } else { 'Custom-RangerRun_CL' } findingStreamName = [string]$la.findingStreamName authMethod = if (-not [string]::IsNullOrWhiteSpace([string]$la.authMethod)) { [string]$la.authMethod } else { 'default' } failRunOnPublishError = [bool]$la.failRunOnPublishError } } function Resolve-RangerBlobPath { <# .SYNOPSIS Substitute {cluster} / {runId} / {yyyy-MM-dd} / {yyyy} / {MM} / {dd} tokens in a path template. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$Template, [Parameter(Mandatory = $true)] [string]$Cluster, [Parameter(Mandatory = $true)] [string]$RunId, [datetime]$Timestamp = (Get-Date).ToUniversalTime() ) $safeCluster = ($Cluster -replace '[^\w\-\.]', '-').Trim('-') $safeRunId = ($RunId -replace '[^\w\-\.]', '-') $s = $Template $s = $s -replace '\{cluster\}', $safeCluster $s = $s -replace '\{runId\}', $safeRunId $s = $s -replace '\{yyyy-MM-dd\}', $Timestamp.ToString('yyyy-MM-dd') $s = $s -replace '\{yyyy\}', $Timestamp.ToString('yyyy') $s = $s -replace '\{MM\}', $Timestamp.ToString('MM') $s = $s -replace '\{dd\}', $Timestamp.ToString('dd') return ($s -replace '/+', '/').TrimStart('/') } function Select-RangerPackageArtifacts { <# .SYNOPSIS Build the ordered list of artifacts to upload from a Ranger package on disk. .DESCRIPTION Applies the include filter (manifest, evidence, packageIndex, runLog, reports, powerbi, full). `full` expands to manifest + evidence + packageIndex + runLog + reports + powerbi. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$PackagePath, [Parameter(Mandatory = $true)] [string[]]$Include ) if (-not (Test-Path -Path $PackagePath -PathType Container)) { throw "Ranger package folder not found: $PackagePath" } $inc = @($Include | ForEach-Object { $_.ToLowerInvariant() }) if ('full' -in $inc) { $inc = @('manifest','evidence','packageindex','runlog','reports','powerbi') } $artifacts = New-Object System.Collections.ArrayList $addIfExists = { param([string]$rel, [string]$category) $full = Join-Path -Path $PackagePath -ChildPath $rel if (Test-Path -Path $full -PathType Leaf) { [void]$artifacts.Add([ordered]@{ category = $category fullPath = (Resolve-Path -Path $full).Path relativePath = ($rel -replace '\\','/') sizeBytes = (Get-Item -Path $full).Length }) } } if ('manifest' -in $inc) { & $addIfExists 'audit-manifest.json' 'manifest' } if ('packageindex' -in $inc) { & $addIfExists 'package-index.json' 'packageIndex' } if ('runlog' -in $inc) { & $addIfExists 'ranger.log' 'runLog' } if ('evidence' -in $inc) { foreach ($f in @(Get-ChildItem -Path $PackagePath -Filter '*-evidence.json' -File -ErrorAction SilentlyContinue)) { [void]$artifacts.Add([ordered]@{ category = 'evidence'; fullPath = $f.FullName; relativePath = $f.Name; sizeBytes = $f.Length }) } foreach ($f in @(Get-ChildItem -Path (Join-Path $PackagePath 'reports') -Filter '*-evidence.json' -File -ErrorAction SilentlyContinue)) { [void]$artifacts.Add([ordered]@{ category = 'evidence'; fullPath = $f.FullName; relativePath = "reports/$($f.Name)"; sizeBytes = $f.Length }) } } if ('reports' -in $inc) { $reportsPath = Join-Path $PackagePath 'reports' if (Test-Path -Path $reportsPath -PathType Container) { foreach ($f in @(Get-ChildItem -Path $reportsPath -File -Recurse -ErrorAction SilentlyContinue)) { if ($f.Name -like '*-evidence.json') { continue } # already added above $rel = [System.IO.Path]::GetRelativePath($PackagePath, $f.FullName) -replace '\\','/' [void]$artifacts.Add([ordered]@{ category = 'reports'; fullPath = $f.FullName; relativePath = $rel; sizeBytes = $f.Length }) } } } if ('powerbi' -in $inc) { $pbiPath = Join-Path $PackagePath 'powerbi' if (Test-Path -Path $pbiPath -PathType Container) { foreach ($f in @(Get-ChildItem -Path $pbiPath -File -ErrorAction SilentlyContinue)) { $rel = [System.IO.Path]::GetRelativePath($PackagePath, $f.FullName) -replace '\\','/' [void]$artifacts.Add([ordered]@{ category = 'powerbi'; fullPath = $f.FullName; relativePath = $rel; sizeBytes = $f.Length }) } } } return @($artifacts) } function Get-RangerFileSha256 { [CmdletBinding()] param([Parameter(Mandatory = $true)] [string]$Path) if (-not (Test-Path -Path $Path -PathType Leaf)) { throw "File not found for hashing: $Path" } return (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLowerInvariant() } function Resolve-RangerBlobAuth { <# .SYNOPSIS v2.3.0 (#244): resolve an auth strategy for Azure Blob according to the documented default chain: Managed Identity → Entra RBAC context → SAS from Key Vault. .OUTPUTS Hashtable with { method: 'managedIdentity'|'entraRbac'|'sasFromKeyVault'|'none'; sasToken?; clientId?; reason? }. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [hashtable]$RemoteStorageConfig ) $requested = [string]$RemoteStorageConfig.authMethod if ([string]::IsNullOrWhiteSpace($requested)) { $requested = 'default' } $resolveSas = { if ([string]::IsNullOrWhiteSpace([string]$RemoteStorageConfig.sasRef)) { return $null } if (-not (Get-Command -Name 'Resolve-RangerCredentialDefinition' -ErrorAction SilentlyContinue)) { return $null } try { $sas = Resolve-RangerCredentialDefinition -Reference ([string]$RemoteStorageConfig.sasRef) if ($sas) { return [string]$sas } } catch { return $null } return $null } if ($requested -eq 'managedIdentity' -or ($requested -eq 'default' -and $env:AZURE_CLIENT_ID)) { return @{ method = 'managedIdentity'; clientId = [string]$env:AZURE_CLIENT_ID } } if ($requested -eq 'sasFromKeyVault') { $sas = & $resolveSas if ($sas) { return @{ method = 'sasFromKeyVault'; sasToken = $sas } } return @{ method = 'none'; reason = "sasFromKeyVault requested but sasRef could not be resolved ($($RemoteStorageConfig.sasRef))" } } if ($requested -in @('default','entraRbac')) { try { if (Get-Command -Name 'Get-AzContext' -ErrorAction SilentlyContinue) { $ctx = Get-AzContext -ErrorAction SilentlyContinue if ($ctx -and $ctx.Account) { return @{ method = 'entraRbac' } } } } catch { } # Last-chance fallback to a Key Vault SAS if one was provided alongside default. $sas = & $resolveSas if ($sas) { return @{ method = 'sasFromKeyVault'; sasToken = $sas } } return @{ method = 'none'; reason = 'No Az.Accounts context and no sasRef configured.' } } return @{ method = 'none'; reason = "Unknown authMethod '$requested'." } } function Invoke-RangerBlobUpload { <# .SYNOPSIS Upload a single file to Azure Blob storage. Idempotent — if the destination already exists and the local SHA-256 matches, skip the PUT. .NOTES Depends on Az.Storage when auth method is managedIdentity / entraRbac, or can also accept a raw SAS URL via the sasFromKeyVault path. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [string]$StorageAccount, [Parameter(Mandatory = $true)] [string]$Container, [Parameter(Mandatory = $true)] [string]$BlobName, [Parameter(Mandatory = $true)] [string]$LocalPath, [Parameter(Mandatory = $true)] [hashtable]$Auth, [hashtable]$BlobTags ) if (-not (Test-Path -Path $LocalPath -PathType Leaf)) { throw "Local blob source not found: $LocalPath" } $sha = Get-RangerFileSha256 -Path $LocalPath if (-not (Get-Command -Name 'Set-AzStorageBlobContent' -ErrorAction SilentlyContinue)) { throw 'Az.Storage module is required for Azure Blob publishing. Install-Module Az.Storage -Scope CurrentUser' } # Build a storage context appropriate for the auth method. $ctx = $null switch ($Auth.method) { 'managedIdentity' { $ctx = New-AzStorageContext -StorageAccountName $StorageAccount -UseConnectedAccount -ErrorAction Stop } 'entraRbac' { $ctx = New-AzStorageContext -StorageAccountName $StorageAccount -UseConnectedAccount -ErrorAction Stop } 'sasFromKeyVault' { $ctx = New-AzStorageContext -StorageAccountName $StorageAccount -SasToken $Auth.sasToken -ErrorAction Stop } default { throw "Unsupported auth method: $($Auth.method)" } } # Idempotency — compare remote metadata.x-ranger-sha256 if present. $existing = $null try { $existing = Get-AzStorageBlob -Container $Container -Blob $BlobName -Context $ctx -ErrorAction SilentlyContinue } catch { } if ($existing -and $existing.ICloudBlob -and $existing.ICloudBlob.Metadata) { $remoteSha = [string]$existing.ICloudBlob.Metadata['x-ranger-sha256'] if ($remoteSha -and $remoteSha -eq $sha) { return [ordered]@{ blobUri = ('https://{0}.blob.core.windows.net/{1}/{2}' -f $StorageAccount, $Container, $BlobName) status = 'skipped-idempotent' sha256 = $sha sizeBytes = (Get-Item -Path $LocalPath).Length } } } $metadata = @{ 'x-ranger-sha256' = $sha } $setParams = @{ File = $LocalPath Container = $Container Blob = $BlobName Context = $ctx Force = $true Metadata = $metadata } if ($BlobTags -and $BlobTags.Count -gt 0) { $setParams['Tag'] = $BlobTags } $result = Set-AzStorageBlobContent @setParams -ErrorAction Stop return [ordered]@{ blobUri = ('https://{0}.blob.core.windows.net/{1}/{2}' -f $StorageAccount, $Container, $BlobName) status = 'uploaded' sha256 = $sha sizeBytes = (Get-Item -Path $LocalPath).Length } } function Invoke-RangerBlobPublish { <# .SYNOPSIS v2.3.0 (#244 + #245): publish a Ranger package to Azure Blob + update catalog blobs. .DESCRIPTION Core orchestrator for the blob sink. Iterates the package artifacts, uploads each, then writes `_catalog/{cluster}/latest.json` and merges `_catalog/_index.json` so downstream consumers can find the latest run without listing. .OUTPUTS Hashtable recorded on manifest.run.cloudPublish — { status, authMethod, blobUris, bytesUploaded, duration, latestBlob, indexBlob, errors }. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest, [Parameter(Mandatory = $true)] [string]$PackagePath, [Parameter(Mandatory = $true)] [hashtable]$RemoteStorageConfig, [switch]$Offline ) $start = Get-Date $cluster = [string]($Manifest.topology.clusterName ?? $Manifest.domains.clusterNode.clusterName ?? $Manifest.run.clusterName ?? 'unknown-cluster') $rawRunId = [string]$Manifest.run.runId $runId = if ([string]::IsNullOrWhiteSpace($rawRunId)) { (Get-Date -Format 'yyyyMMddHHmmss') } else { $rawRunId } $mode = [string]$Manifest.run.mode $tool = [string]$Manifest.run.toolVersion $basePath = Resolve-RangerBlobPath -Template $RemoteStorageConfig.pathTemplate -Cluster $cluster -RunId $runId $baseTags = @{ cluster = $cluster; mode = $mode; toolVersion = $tool; runId = $runId } if ($RemoteStorageConfig.blobTags) { foreach ($k in $RemoteStorageConfig.blobTags.Keys) { $baseTags[[string]$k] = [string]$RemoteStorageConfig.blobTags[$k] } } $result = [ordered]@{ status = 'pending' authMethod = 'unknown' basePath = $basePath blobUris = @() bytesUploaded = 0 durationMs = 0 latestBlob = $null indexBlob = $null errors = @() } try { $artifacts = Select-RangerPackageArtifacts -PackagePath $PackagePath -Include $RemoteStorageConfig.include if ($artifacts.Count -eq 0) { $result.status = 'skipped' $result.errors = @('No artifacts matched the configured include filter.') return $result } if ($Offline) { # Offline mode — simulate uploads for tests / fixture runs. $result.authMethod = 'offline' $blobUris = New-Object System.Collections.ArrayList $totalBytes = 0 foreach ($a in $artifacts) { $blobName = "$basePath/$($a.relativePath)" [void]$blobUris.Add(('offline://{0}/{1}/{2}' -f $RemoteStorageConfig.storageAccount, $RemoteStorageConfig.container, $blobName)) $totalBytes += [long]$a.sizeBytes } $result.blobUris = @($blobUris) $result.bytesUploaded = [long]$totalBytes $result.status = 'ok' $result.latestBlob = "offline://{0}/{1}/_catalog/{2}/latest.json" -f $RemoteStorageConfig.storageAccount, $RemoteStorageConfig.container, $cluster $result.indexBlob = "offline://{0}/{1}/_catalog/_index.json" -f $RemoteStorageConfig.storageAccount, $RemoteStorageConfig.container $result.durationMs = [int]((Get-Date) - $start).TotalMilliseconds return $result } $auth = Resolve-RangerBlobAuth -RemoteStorageConfig $RemoteStorageConfig $result.authMethod = $auth.method if ($auth.method -eq 'none') { $result.status = 'failed' $result.errors = @("auth: $($auth.reason)") return $result } $blobUris = New-Object System.Collections.ArrayList $totalBytes = 0 $errors = New-Object System.Collections.ArrayList foreach ($a in $artifacts) { $blobName = "$basePath/$($a.relativePath)" try { $up = Invoke-RangerBlobUpload -StorageAccount $RemoteStorageConfig.storageAccount -Container $RemoteStorageConfig.container -BlobName $blobName -LocalPath $a.fullPath -Auth $auth -BlobTags $baseTags [void]$blobUris.Add($up.blobUri) $totalBytes += [long]$up.sizeBytes } catch { [void]$errors.Add(("{0}: {1}" -f $a.relativePath, $_.Exception.Message)) } } $result.blobUris = @($blobUris) $result.bytesUploaded = [long]$totalBytes $result.errors = @($errors) # v2.3.0 (#245): catalog blobs — written regardless of partial upload failures, # because downstream consumers need a pointer to whatever did land. if (@($blobUris).Count -gt 0) { $catalogResult = Update-RangerCloudCatalog -Manifest $Manifest -Auth $auth -RemoteStorageConfig $RemoteStorageConfig -BasePath $basePath $result.latestBlob = $catalogResult.latestBlob $result.indexBlob = $catalogResult.indexBlob if ($catalogResult.errors) { foreach ($e in $catalogResult.errors) { [void]$result.errors.Add($e) } } } $result.status = if (@($result.errors).Count -eq 0) { 'ok' } elseif (@($blobUris).Count -gt 0) { 'partial' } else { 'failed' } } catch { $result.status = 'failed' $result.errors = @($_.Exception.Message) } finally { $result.durationMs = [int]((Get-Date) - $start).TotalMilliseconds } return $result } function Update-RangerCloudCatalog { <# .SYNOPSIS v2.3.0 (#245): write `_catalog/{cluster}/latest.json` and merge `_catalog/_index.json`. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest, [Parameter(Mandatory = $true)] [hashtable]$Auth, [Parameter(Mandatory = $true)] [hashtable]$RemoteStorageConfig, [Parameter(Mandatory = $true)] [string]$BasePath ) $cluster = [string]($Manifest.topology.clusterName ?? $Manifest.domains.clusterNode.clusterName ?? $Manifest.run.clusterName ?? 'unknown-cluster') $runId = [string]$Manifest.run.runId $errors = New-Object System.Collections.ArrayList $scoreBlock = [ordered]@{ overall = [int]($Manifest.domains.wafAssessment.summary.overallScore ?? 0) status = [string]($Manifest.domains.wafAssessment.summary.status ?? '') pillars = @{} } if ($Manifest.domains.wafAssessment.pillarScores) { foreach ($p in @($Manifest.domains.wafAssessment.pillarScores)) { $scoreBlock.pillars[[string]$p.pillar] = [int]$p.score } } $latest = [ordered]@{ schemaVersion = '1.0' cluster = $cluster lastUpdatedUtc = (Get-Date).ToUniversalTime().ToString('o') run = [ordered]@{ runId = $runId mode = [string]$Manifest.run.mode toolVersion = [string]$Manifest.run.toolVersion generatedAtUtc = [string]$Manifest.run.endTimeUtc basePath = $BasePath artifacts = [ordered]@{ manifest = "$BasePath/audit-manifest.json" evidence = "$BasePath/$runId-evidence.json" packageIndex = "$BasePath/package-index.json" } score = $scoreBlock } } $tempDir = [System.IO.Path]::GetTempPath() $latestTemp = Join-Path $tempDir "ranger-latest-$([guid]::NewGuid().ToString()).json" $latest | ConvertTo-Json -Depth 10 | Set-Content -Path $latestTemp -Encoding UTF8 $latestBlob = "_catalog/$cluster/latest.json" $latestUri = $null try { $up = Invoke-RangerBlobUpload -StorageAccount $RemoteStorageConfig.storageAccount -Container $RemoteStorageConfig.container -BlobName $latestBlob -LocalPath $latestTemp -Auth $Auth -BlobTags @{ cluster = $cluster; kind = 'latest' } $latestUri = $up.blobUri } catch { [void]$errors.Add("latest.json: $($_.Exception.Message)") } finally { Remove-Item -Path $latestTemp -ErrorAction SilentlyContinue } # _index.json — read-modify-write with ETag; if read fails we create a fresh one. $indexEntry = [ordered]@{ cluster = $cluster latestRunId = $runId latestAtUtc = $latest.lastUpdatedUtc latestScore = $scoreBlock.overall latestStatus = $scoreBlock.status catalogBlob = $latestBlob } $ctx = $null try { switch ($Auth.method) { 'managedIdentity' { $ctx = New-AzStorageContext -StorageAccountName $RemoteStorageConfig.storageAccount -UseConnectedAccount -ErrorAction Stop } 'entraRbac' { $ctx = New-AzStorageContext -StorageAccountName $RemoteStorageConfig.storageAccount -UseConnectedAccount -ErrorAction Stop } 'sasFromKeyVault' { $ctx = New-AzStorageContext -StorageAccountName $RemoteStorageConfig.storageAccount -SasToken $Auth.sasToken -ErrorAction Stop } } } catch { [void]$errors.Add("_index context: $($_.Exception.Message)") } $indexBlob = '_catalog/_index.json' $indexUri = $null if ($ctx) { $existing = $null try { $existing = Get-AzStorageBlob -Container $RemoteStorageConfig.container -Blob $indexBlob -Context $ctx -ErrorAction SilentlyContinue } catch { } $doc = $null if ($existing) { try { $tmpDl = Join-Path $tempDir "ranger-index-$([guid]::NewGuid().ToString()).json" $null = Get-AzStorageBlobContent -Container $RemoteStorageConfig.container -Blob $indexBlob -Destination $tmpDl -Context $ctx -Force -ErrorAction Stop $doc = Get-Content -Path $tmpDl -Raw | ConvertFrom-Json -AsHashtable -Depth 20 Remove-Item -Path $tmpDl -ErrorAction SilentlyContinue } catch { $doc = $null } } if (-not $doc) { $doc = [ordered]@{ schemaVersion = '1.0'; lastUpdatedUtc = $latest.lastUpdatedUtc; clusters = @() } } $clusters = @($doc.clusters | Where-Object { $_.cluster -ne $cluster }) $clusters += $indexEntry $doc.clusters = @($clusters) $doc.lastUpdatedUtc = $latest.lastUpdatedUtc $indexTemp = Join-Path $tempDir "ranger-indexwrite-$([guid]::NewGuid().ToString()).json" $doc | ConvertTo-Json -Depth 20 | Set-Content -Path $indexTemp -Encoding UTF8 try { $up2 = Invoke-RangerBlobUpload -StorageAccount $RemoteStorageConfig.storageAccount -Container $RemoteStorageConfig.container -BlobName $indexBlob -LocalPath $indexTemp -Auth $Auth -BlobTags @{ kind = 'index' } $indexUri = $up2.blobUri } catch { [void]$errors.Add("_index.json: $($_.Exception.Message)") } finally { Remove-Item -Path $indexTemp -ErrorAction SilentlyContinue } } # Optional per-run history — one JSONL line appended. if ($RemoteStorageConfig.writeHistory -and $ctx) { $historyBlob = "_catalog/$cluster/history.jsonl" $histLine = ($indexEntry | ConvertTo-Json -Depth 5 -Compress) $histTemp = Join-Path $tempDir "ranger-history-$([guid]::NewGuid().ToString()).jsonl" # Try to download existing then append. $existingHist = $null try { $null = Get-AzStorageBlobContent -Container $RemoteStorageConfig.container -Blob $historyBlob -Destination $histTemp -Context $ctx -Force -ErrorAction Stop } catch { New-Item -Path $histTemp -ItemType File -Force | Out-Null } Add-Content -Path $histTemp -Value $histLine try { $null = Invoke-RangerBlobUpload -StorageAccount $RemoteStorageConfig.storageAccount -Container $RemoteStorageConfig.container -BlobName $historyBlob -LocalPath $histTemp -Auth $Auth -BlobTags @{ cluster = $cluster; kind = 'history' } } catch { [void]$errors.Add("history.jsonl: $($_.Exception.Message)") } finally { Remove-Item -Path $histTemp -ErrorAction SilentlyContinue } } return [ordered]@{ latestBlob = $latestUri indexBlob = $indexUri errors = @($errors) } } function Build-RangerLogAnalyticsPayload { <# .SYNOPSIS v2.3.0 (#247): build the RangerRun_CL + RangerFinding_CL payloads from a manifest. .OUTPUTS Hashtable with `run` (single record) and `findings` (0..N records). #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest, [hashtable]$CloudPublish ) $cluster = [string]($Manifest.topology.clusterName ?? $Manifest.domains.clusterNode.clusterName ?? $Manifest.run.clusterName ?? 'unknown-cluster') $wafSummary = $Manifest.domains.wafAssessment.summary $pillarDict = [ordered]@{} foreach ($p in @($Manifest.domains.wafAssessment.pillarScores)) { $pillarDict[[string]$p.pillar -replace '\s','_'] = [int]$p.score } $run = [ordered]@{ TimeGenerated = (Get-Date).ToUniversalTime().ToString('o') Cluster = $cluster RunId = [string]$Manifest.run.runId Mode = [string]$Manifest.run.mode ToolVersion = [string]$Manifest.run.toolVersion WafOverallScore = if ($wafSummary.overallScore) { [int]$wafSummary.overallScore } else { 0 } WafStatus = [string]$wafSummary.status WafPillarScores = $pillarDict FailingRuleCount = if ($wafSummary.failingRules) { [int]$wafSummary.failingRules } else { 0 } AhbStatus = [string]($Manifest.domains.azureIntegration.costLicensing.summary.ahbStatus ?? '') AhbAdoptionPct = if ($Manifest.domains.azureIntegration.costLicensing.summary.ahbAdoptionPct) { [double]$Manifest.domains.azureIntegration.costLicensing.summary.ahbAdoptionPct } else { 0 } CoresWithAhb = if ($Manifest.domains.azureIntegration.costLicensing.summary.ahbEnrolledCores) { [int]$Manifest.domains.azureIntegration.costLicensing.summary.ahbEnrolledCores } else { 0 } NodeCount = @($Manifest.domains.clusterNode.nodes).Count VmCount = @($Manifest.domains.virtualMachines.inventory).Count StoragePools = @($Manifest.domains.storage.pools).Count CloudPublishStatus = if ($CloudPublish) { [string]$CloudPublish.status } else { 'skipped' } CimDepthStatus = [string]($Manifest.run.remoteExecution.cimDepth.status ?? '') ManifestBlobUri = if ($CloudPublish -and $CloudPublish.blobUris) { @($CloudPublish.blobUris | Where-Object { $_ -like '*audit-manifest.json' }) | Select-Object -First 1 } else { '' } EvidenceBlobUri = if ($CloudPublish -and $CloudPublish.blobUris) { @($CloudPublish.blobUris | Where-Object { $_ -like '*evidence.json' }) | Select-Object -First 1 } else { '' } } $findings = New-Object System.Collections.ArrayList $rules = @($Manifest.domains.wafAssessment.ruleResults) if ($rules.Count -eq 0 -and (Get-Command -Name Invoke-RangerWafRuleEvaluation -ErrorAction SilentlyContinue)) { try { $eval = Invoke-RangerWafRuleEvaluation -Manifest $Manifest $rules = @($eval.ruleResults) } catch { } } foreach ($rr in $rules) { if ($rr.pass) { continue } $rem = $rr.remediation [void]$findings.Add([ordered]@{ TimeGenerated = $run.TimeGenerated Cluster = $cluster RunId = $run.RunId RuleId = [string]$rr.id Pillar = [string]$rr.pillar Severity = [string]$rr.severity Weight = if ($rr.weight) { [int]$rr.weight } else { 1 } Message = [string]$rr.title Recommendation = if ($rem -and $rem.steps -and @($rem.steps).Count -gt 0) { [string]@($rem.steps)[0] } else { [string]$rr.recommendation } }) } return @{ run = $run findings = @($findings) } } function Invoke-RangerLogAnalyticsPublish { <# .SYNOPSIS v2.3.0 (#247): POST the distilled run + findings records to a DCE/DCR pair via Logs Ingestion API. .DESCRIPTION Non-blocking by default — failures record `manifest.run.logAnalytics.status = 'failed'` rather than aborting the run. #> [CmdletBinding()] param( [Parameter(Mandatory = $true)] [System.Collections.IDictionary]$Manifest, [Parameter(Mandatory = $true)] [hashtable]$LogAnalyticsConfig, [hashtable]$CloudPublish, [switch]$Offline ) $start = Get-Date $result = [ordered]@{ status = 'pending' authMethod = [string]$LogAnalyticsConfig.authMethod runRowsPosted = 0 findingRowsPosted = 0 durationMs = 0 errors = @() } try { $payload = Build-RangerLogAnalyticsPayload -Manifest $Manifest -CloudPublish $CloudPublish if ($Offline) { $result.status = 'ok-offline' $result.runRowsPosted = 1 $result.findingRowsPosted = @($payload.findings).Count $result.durationMs = [int]((Get-Date) - $start).TotalMilliseconds return $result } $dce = [string]$LogAnalyticsConfig.dataCollectionEndpoint $dcr = [string]$LogAnalyticsConfig.dataCollectionRuleImmutableId if ([string]::IsNullOrWhiteSpace($dce) -or [string]::IsNullOrWhiteSpace($dcr)) { $result.status = 'failed' $result.errors = @('dataCollectionEndpoint and dataCollectionRuleImmutableId are required.') return $result } # Acquire token — caller must be logged in via Az.Accounts or be running with an MI. $token = $null try { if (Get-Command -Name Get-AzAccessToken -ErrorAction SilentlyContinue) { $token = (Get-AzAccessToken -ResourceUrl 'https://monitor.azure.com/' -ErrorAction Stop).Token } } catch { $result.errors = @($_.Exception.Message) } if (-not $token) { $result.status = 'failed' $result.errors += 'Could not acquire monitor.azure.com access token. Check Az.Accounts context / MI.' return $result } $headers = @{ Authorization = "Bearer $token"; 'Content-Type' = 'application/json' } $runUrl = "$dce/dataCollectionRules/$dcr/streams/$($LogAnalyticsConfig.streamName)?api-version=2023-01-01" $body = ConvertTo-Json -InputObject @($payload.run) -Depth 10 -Compress try { $null = Invoke-RestMethod -Method Post -Uri $runUrl -Headers $headers -Body $body -ErrorAction Stop $result.runRowsPosted = 1 } catch { $result.errors += "run post: $($_.Exception.Message)" } if (-not [string]::IsNullOrWhiteSpace([string]$LogAnalyticsConfig.findingStreamName) -and @($payload.findings).Count -gt 0) { $findingUrl = "$dce/dataCollectionRules/$dcr/streams/$($LogAnalyticsConfig.findingStreamName)?api-version=2023-01-01" $fbody = ConvertTo-Json -InputObject @($payload.findings) -Depth 10 -Compress try { $null = Invoke-RestMethod -Method Post -Uri $findingUrl -Headers $headers -Body $fbody -ErrorAction Stop $result.findingRowsPosted = @($payload.findings).Count } catch { $result.errors += "finding post: $($_.Exception.Message)" } } $result.status = if ($result.runRowsPosted -gt 0 -and @($result.errors).Count -eq 0) { 'ok' } elseif ($result.runRowsPosted -gt 0) { 'partial' } else { 'failed' } } catch { $result.status = 'failed' $result.errors = @($_.Exception.Message) } finally { $result.durationMs = [int]((Get-Date) - $start).TotalMilliseconds } return $result } |