AzPolicyLens.Discovery.helper.psm1

<#
==========================================================================================
AUTHOR: Tao Yang
DATE: 06/06/2024
NAME: AzPolicyLens.Discovery.utilities.psm1
VERSION: 1.0.0
COMMENT: this nested module contains the helper functions for AzPolicyLens.Discovery module
==========================================================================================
#>


#function to get the current UTC time
function getCurrentUTCString {
  "$([DateTime]::UtcNow.ToString('u')) UTC"
}


#function to generate AES key and IV
function newAesKey {
  [CmdletBinding()]
  [OutputType([PSCustomObject])]
  param (
    [Parameter(Mandatory = $false)]
    [ValidateSet(128, 192, 256)]
    [int]$KeySize = 256,

    [Parameter(Mandatory = $false)]
    [string]$OutputFilePath
  )

  try {
    Write-Verbose "[$(getCurrentUTCString)]: Generating AES-$KeySize key and IV"

    # Create AES instance
    $aes = [System.Security.Cryptography.Aes]::Create()
    $aes.KeySize = $KeySize
    $aes.GenerateKey()
    $aes.GenerateIV()

    # Create key object
    $keyObject = [PSCustomObject]@{
      Key     = [Convert]::ToBase64String($aes.Key)
      IV      = [Convert]::ToBase64String($aes.IV)
      KeySize = $KeySize
      Created = (Get-Date).ToUniversalTime().ToString('o')
    }

    # Save to file if path specified
    if ($OutputFilePath) {
      $keyJson = $keyObject | ConvertTo-Json
      [System.IO.File]::WriteAllText($OutputFilePath, $keyJson, [System.Text.Encoding]::UTF8)
      Write-Verbose "[$(getCurrentUTCString)]: AES key saved to: $OutputFilePath"
    }

    # Dispose of AES instance
    $aes.Dispose()

    return $keyObject
  } catch {
    Write-Error "[$(getCurrentUTCString)]: Failed to generate AES key: $_"
    throw
  }
}

#function to encrypt file or text using AES
function encryptStuff {
  [CmdletBinding()]
  [OutputType([string])]
  param (
    [Parameter(Mandatory = $true, ParameterSetName = 'FileInKeyFile')]
    [Parameter(Mandatory = $true, ParameterSetName = 'FileInKeyDirect')]
    [ValidateScript({ Test-Path $_ -PathType Leaf })]
    [string]$InputFilePath,

    [Parameter(Mandatory = $true, ParameterSetName = 'TextInKeyFile')]
    [Parameter(Mandatory = $true, ParameterSetName = 'TextInKeyDirect')]
    [ValidateNotNullOrEmpty()]
    [string]$InputText,

    [Parameter(Mandatory = $false)]
    [string]$OutputFilePath,

    [Parameter(Mandatory = $true, ParameterSetName = 'FileInKeyFile')]
    [Parameter(Mandatory = $true, ParameterSetName = 'TextInKeyFile')]
    [ValidateScript({ Test-Path $_ -PathType Leaf })]
    [string]$KeyFilePath,

    [Parameter(Mandatory = $true, ParameterSetName = 'FileInKeyDirect')]
    [Parameter(Mandatory = $true, ParameterSetName = 'TextInKeyDirect')]
    [string]$AESKey,

    [Parameter(Mandatory = $true, ParameterSetName = 'FileInKeyDirect')]
    [Parameter(Mandatory = $true, ParameterSetName = 'TextInKeyDirect')]
    [string]$AESIV,

    [Parameter(Mandatory = $false)]
    [switch]$UseCompression
  )

  try {
    Write-Verbose "[$(getCurrentUTCString)]: Starting AES encryption"
    $startTime = [datetime]::UtcNow

    # Load key and IV
    if ($PSCmdlet.ParameterSetName -eq 'FileInKeyFile' -or $PSCmdlet.ParameterSetName -eq 'TextInKeyFile') {
      $KeyFullPath = (Resolve-Path -Path $KeyFilePath).path
      Write-Verbose "[$(getCurrentUTCString)]: Loading key from file: $KeyFullPath"
      $keyJson = [System.IO.File]::ReadAllText($KeyFullPath)
      $keyObject = $keyJson | ConvertFrom-Json
      $keyBytes = [Convert]::FromBase64String($keyObject.Key)
      $ivBytes = [Convert]::FromBase64String($keyObject.IV)
    } else {
      $keyBytes = [Convert]::FromBase64String($AESKey)
      $ivBytes = [Convert]::FromBase64String($AESIV)
    }

    # Read input data
    if ($InputFilePath) {
      $inputFileFullPath = (Resolve-Path -Path $InputFilePath).path
      Write-Verbose "[$(getCurrentUTCString)]: Reading input file: $inputFileFullPath"
      $inputBytes = [System.IO.File]::ReadAllBytes($inputFileFullPath)
    } else {
      $inputBytes = [System.Text.Encoding]::UTF8.GetBytes($InputText)
    }

    Write-Verbose "[$(getCurrentUTCString)]: Input size: $($inputBytes.Length) bytes"

    # Compress if requested
    if ($UseCompression) {
      Write-Verbose "[$(getCurrentUTCString)]: Compressing data"
      $memoryStream = New-Object System.IO.MemoryStream
      $gzipStream = New-Object System.IO.Compression.GZipStream($memoryStream, [System.IO.Compression.CompressionMode]::Compress)
      $gzipStream.Write($inputBytes, 0, $inputBytes.Length)
      $gzipStream.Close()
      $inputBytes = $memoryStream.ToArray()
      $memoryStream.Dispose()
      Write-Verbose "[$(getCurrentUTCString)]: Compressed size: $($inputBytes.Length) bytes"
    }

    # Create AES instance
    $aes = [System.Security.Cryptography.Aes]::Create()
    $aes.Key = $keyBytes
    $aes.IV = $ivBytes
    $aes.Mode = [System.Security.Cryptography.CipherMode]::CBC
    $aes.Padding = [System.Security.Cryptography.PaddingMode]::PKCS7

    # Create encryptor
    $encryptor = $aes.CreateEncryptor()

    # Encrypt data
    Write-Verbose "[$(getCurrentUTCString)]: Encrypting data"
    $encryptedBytes = $encryptor.TransformFinalBlock($inputBytes, 0, $inputBytes.Length)

    # Create output object with metadata
    $outputObject = [PSCustomObject]@{
      Algorithm     = "AES-$($aes.KeySize)"
      Compressed    = $UseCompression.IsPresent
      OriginalSize  = if ($InputFilePath) { (Get-Item $InputFilePath).Length } else { $InputText.Length }
      EncryptedData = [Convert]::ToBase64String($encryptedBytes)
      EncryptedAt   = (Get-Date).ToUniversalTime().ToString('o')
    }
    $outputJson = $outputObject | ConvertTo-Json
    # Cleanup
    $encryptor.Dispose()
    $aes.Dispose()

    $endTime = [datetime]::UtcNow
    $timeTaken = New-TimeSpan -Start $startTime -End $endTime
    Write-Verbose "[$(getCurrentUTCString)]: AES encryption completed in $($timeTaken.TotalSeconds) seconds"

    if ($OutputFilePath) {
      # Save to file
      [System.IO.File]::WriteAllText($OutputFilePath, $outputJson, [System.Text.Encoding]::UTF8)
      Write-Verbose "[$(getCurrentUTCString)]: Encrypted file saved to: $OutputFilePath"
      return $OutputFilePath
    } else {
      # Return JSON string
      return $outputJson
    }
  } catch {
    Write-Error "[$(getCurrentUTCString)]: AES encryption failed: $_"
    throw
  } finally {
    if ($aes) { $aes.Dispose() }
    if ($encryptor) { $encryptor.Dispose() }
  }
}

#function to convert a PSCustomObject to an ordered hashtable
function ConvertToOrderedHashtable {
  [CmdletBinding()]
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
  param (
    [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
    [PSCustomObject]$InputObject
  )
  process {
    $ordered = [ordered]@{}

    # Get all properties and add them to the ordered hashtable
    $InputObject.PSObject.Properties | ForEach-Object {
      $ordered[$_.Name] = $_.Value
    }

    return $ordered
  }
}

#function to query Azure resource graph using the rest API
function invokeARGQueryREST {
  [CmdletBinding()]
  [OutputType([system.object[]])]
  param (
    [parameter(Mandatory = $true)]
    [string]$query,

    [parameter(Mandatory = $false)]
    [string[]]$scope,

    [parameter(Mandatory = $true)]
    [ValidateSet('tenant', 'subscription', 'managementGroup')]
    [string]$scopeType,

    [parameter(Mandatory = $false)]
    [ValidateRange(1, 1000)]
    [int]$top = 1000,

    [parameter(Mandatory = $false)]
    [int]$skip = 0,

    [parameter(Mandatory = $true)]
    [string]$token,

    [parameter(Mandatory = $false)]
    [ValidateRange(30, 600)]
    [int]$timeoutSeconds = 300,

    [parameter(Mandatory = $false)]
    [ValidateRange(1, 5)]
    [int]$maxRetries = 3,

    [parameter(Mandatory = $false)]
    [bool]$enablePagination = $true
  )

  # Build the request URI
  $uri = 'https://management.azure.com/providers/Microsoft.ResourceGraph/resources?api-version=2024-04-01'

  # Build request headers
  $headers = @{
    'Authorization' = "Bearer $token"
    'Content-Type'  = 'application/json'
  }

  # Initialize collections for all results
  $allResults = @()
  $totalCount = 0
  $skipToken = $null
  $currentSkip = $skip
  $pageNumber = 1

  do {
    # Build request body
    $body = @{
      query   = $query
      options = @{
      }
    }
    #only set the $top and $skip option if pagination is disabled
    if (!$enablePagination) {
      $body.options.'$top' = $top
      $body.options.'$skip' = $currentSkip
    }
    # Add skip token if present (for pagination)
    if ($skipToken) {
      $body.options.'$skipToken' = $skipToken
    }

    # Add subscriptions if provided
    if ($scopeType -ieq 'subscription' -and $scope -and $scope.Count -gt 0) {
      $body.subscriptions = $scope
    } elseif ($scopeType -ieq 'managementGroup' -and $scope -and $scope.Count -gt 0) {
      # If scope is management group, convert to management group names
      $body.managementGroups = $scope
    }

    $jsonBody = $body | ConvertTo-Json -Depth 10
    Write-Verbose "[$(getCurrentUTCString)]: Querying Azure Resource Graph via REST API (Page $pageNumber)"
    Write-Verbose "[$(getCurrentUTCString)]: Query: $query"
    Write-Verbose "[$(getCurrentUTCString)]: Request URI: $uri"
    if ($skipToken) {
      Write-Verbose "[$(getCurrentUTCString)]: Using skip token for pagination"
    }

    $retryCount = 0
    $success = $false
    $pageResult = $null

    do {
      try {
        $skipToken = $null
        $retryCount++
        Write-Verbose "[$(getCurrentUTCString)]: Attempt $retryCount/$maxRetries (Page $pageNumber)"

        $response = Invoke-WebRequest -Uri $uri -Method POST -Headers $headers -Body $jsonBody -TimeoutSec $timeoutSeconds

        if ($response.StatusCode -eq 200) {
          $pageResult = ($response.Content | ConvertFrom-Json -Depth 99)
          $success = $true

          $pageCount = if ($pageResult.data) { $pageResult.data.Count } else { 0 }
          Write-Verbose "[$(getCurrentUTCString)]: Successfully retrieved $pageCount resources from Azure Resource Graph (Page $pageNumber)"

          # Handle truncated results
          if ($pageResult.truncated -eq $true) {
            Write-Warning "[$(getCurrentUTCString)]: Results were truncated on page $pageNumber. Consider refining your query."
          }

          # Add results to collection
          if ($pageResult.data -and $pageResult.data.Count -gt 0) {
            $allResults += $pageResult.data
            $totalCount += $pageResult.data.Count
          }

          # Check for more pages
          $skipToken = $pageResult.'$skipToken'

          if ($skipToken -and $enablePagination) {
            Write-Verbose "[$(getCurrentUTCString)]: Skip token found. More pages available."
            $pageNumber++
            # Reset skip for next page when using skip token
            $currentSkip = 0
          } elseif ($skipToken -and !$enablePagination) {
            Write-Warning "[$(getCurrentUTCString)]: More results available but pagination is disabled. Set -enablePagination to true to retrieve all results."
            break
          }

        } else {
          Write-Warning "[$(getCurrentUTCString)]: Unexpected status code: $($response.StatusCode)"
        }
      } catch {
        $statusCode = $null
        if ($_.Exception.Response) {
          $statusCode = [int]$_.Exception.Response.StatusCode
        }

        Write-Verbose "[$(getCurrentUTCString)]: Error occurred during Azure Resource Graph query (Page $pageNumber)"
        Write-Verbose "[$(getCurrentUTCString)]: HTTP Status Code: $statusCode"
        Write-Verbose "[$(getCurrentUTCString)]: Error: $($_.Exception.Message)"

        # Handle specific error scenarios
        if ($statusCode -eq 429 -or $statusCode -eq 503) {
          # Rate limiting or service unavailable - retry with exponential backoff
          if ($retryCount -lt $maxRetries) {
            $waitSeconds = [math]::Pow(2, $retryCount) + (Get-Random -Minimum 1 -Maximum 5)
            Write-Verbose "[$(getCurrentUTCString)]: Rate limited or service unavailable. Waiting $waitSeconds seconds before retry."
            Start-Sleep -Seconds $waitSeconds
          }
        } elseif ($statusCode -eq 400) {
          # Bad request - don't retry
          Write-Error "[$(getCurrentUTCString)]: Bad request. Please check your query syntax and parameters."
          return $null
        } elseif ($statusCode -eq 401 -or $statusCode -eq 403) {
          # Authentication/authorization error - don't retry
          Write-Error "[$(getCurrentUTCString)]: Authentication or authorization failed. Please check your permissions."
          return $null
        } else {
          # Other errors - retry with standard backoff
          if ($retryCount -lt $maxRetries) {
            $waitSeconds = Get-Random -Minimum 5 -Maximum 15
            Write-Verbose "[$(getCurrentUTCString)]: Retrying in $waitSeconds seconds..."
            Start-Sleep -Seconds $waitSeconds
          }
        }
      }
    } while ($retryCount -lt $maxRetries -and !$success)

    if (!$success) {
      Write-Error "[$(getCurrentUTCString)]: Failed to query Azure Resource Graph on page $pageNumber after $maxRetries attempts."
      # Return partial results if we have any
      if ($allResults.Count -gt 0) {
        Write-Warning "[$(getCurrentUTCString)]: Returning partial results ($($allResults.Count) items) due to failure on page $pageNumber."
        return $allResults
      }
      return $null
    }

  } while ($skipToken -and $enablePagination -and $success)

  Write-Verbose "[$(getCurrentUTCString)]: Completed pagination. Total results: $totalCount across $pageNumber page(s)"

  return $allResults
}

#function to get Policy resources using Azure Resource Graph
function getPolicyResources {
  [CmdletBinding()]
  [OutputType([System.Object[]])]
  param (
    [parameter(ParameterSetName = 'byId', Mandatory = $true, HelpMessage = "Specify the resource Id of the Policy resource.")]
    [string[]]$ResourceIds,

    [parameter(ParameterSetName = 'byManagementGroupHierarchy', Mandatory = $true, HelpMessage = "Specify the resource type.")]
    [ValidateSet('assignment', 'definition', 'initiative', 'exemption', 'policyMetadata')]
    [string]$ResourceType,

    [parameter(ParameterSetName = 'byManagementGroupHierarchy', Mandatory = $true, HelpMessage = "Specify the resource Id of management group at the top of the hierarchy.")]
    [string]$ManagementGroupName,

    [parameter(Mandatory = $true, HelpMessage = "The Azure OAuth token for accessing 'https://management.azure.com/'. This is required to invoke the Azure Resource Graph query via it's REST API.")]
    [ValidateNotNullOrEmpty()]
    [string]$Token
  )
  $searchParams = @{
    scopeType = 'tenant'
    token     = $Token
  }
  if ($PSCmdlet.ParameterSetName -eq 'byId') {
    #convert multiple resourceIds to string
    $arrResourceId = @()
    foreach ($item in $ResourceIds) {
      $arrResourceId += "'$item'"
    }
    $resourceId = $arrResourceId -join ','
    $resourceId = $resourceId.ToLower()
    $Query = "policyresources | where tolower(id) in ($resourceId)"
  } else {
    switch ($ResourceType) {
      'assignment' { $type = 'microsoft.authorization/policyassignments' }
      'definition' { $type = 'microsoft.authorization/policydefinitions' }
      'initiative' { $type = 'microsoft.authorization/policysetdefinitions' }
      'exemption' { $type = 'microsoft.authorization/policyexemptions' }
    }
    $Query = "policyresources | where type =~ '{0}'" -f $type
    $searchParams.scopeType = 'managementGroup'
    $searchParams.Add('scope', $ManagementGroupName)
  }
  $searchParams.Add('query', $Query)
  $result = invokeARGQueryREST @searchParams
  if ($result) {
    Write-Verbose "[$(getCurrentUTCString)]: Found policy resource in the $($searchParams.scopeType) scope"
  } else {
    Write-Verbose "[$(getCurrentUTCString)]: No policy resources found in the $($searchParams.scopeType) scope"
    $result = @()
  }
  return , $result
}

#function to get all assignments, initiatives and definitions that are currently assigned and store them in a variable so that we don't have to keep querying ARG
function getAllPolicyResources {
  [CmdletBinding()]
  [OutputType([Hashtable])]
  param (
    [parameter(Mandatory = $true)][string]$ManagementGroupName,
    [parameter(Mandatory = $true)][string]$Token,
    [parameter(Mandatory = $false)][BuiltInSecurityControlConfig[]]$additionalBuiltInPolicyMetadataConfig
  )
  $initiativeResourceIdRegex = '(?im)\/providers\/microsoft\.authorization\/policysetdefinitions\/'
  $definitionResourceIdRegex = '(?im)\/providers\/microsoft\.authorization\/policydefinitions\/'
  $policyMetadataResourceIdRegex = '(?im)^\/providers\/microsoft\.policyinsights\/policyMetadata\/'
  $builtInDefinitionResourceIdRegex = '(?im)^\/providers\/microsoft\.authorization\/policydefinitions\/'
  $initiativeResourceIds = @()
  $definitionResourceIds = @()
  $assignmentResourceIds = @()
  $builtInDefinitionInUnassignedInitiativeResourceIds = @()
  $policyMetadataResourceIds = @()
  Write-Verbose "[$(getCurrentUTCString)]: Getting all policy Assignments under management group '$ManagementGroupName'."
  $assignments = getPolicyResources -ResourceType assignment -ManagementGroupName $ManagementGroupName -Token $Token
  Write-Verbose "[$(getCurrentUTCString)]: Found $($assignments.Count) policy assignments under management group '$ManagementGroupName'."

  #Get all assigned initiatives and definitions
  foreach ($assignment in $assignments) {
    $assignmentResourceIds += "'$($assignment.id.tolower())'"
    $defId = $assignment.properties.policyDefinitionId.tolower()
    Write-Verbose "[$(getCurrentUTCString)]: Policy assignment '$($assignment.name)'is used to assign '$defId'."
    if ($defId -match $initiativeResourceIdRegex) {
      #a policy initiative is assigned
      if ($initiativeResourceIds -notcontains $defId) {
        $initiativeResourceIds += $defId
      }
    } elseif ($defId -match $definitionResourceIdRegex) {
      #a policy definition is assigned
      if ($definitionResourceIds -notcontains $defId) {
        $definitionResourceIds += $defId
      }
    } else {
      Write-Error "[$(getCurrentUTCString)]: Unable to detect the type of the assigned policy definition Id '$defId'."
    }
  }

  #get compliance percentage for each assignment
  $assignmentResourceId = $assignmentResourceIds -join ','
  $assignmentComplianceQuery = @"
PolicyResources
| where type =~ 'Microsoft.PolicyInsights/PolicyStates'
| extend complianceState = tostring(properties.complianceState)
| extend
  resourceId = tostring(properties.resourceId),
  policyAssignmentId = tostring(properties.policyAssignmentId),
  policyAssignmentScope = tostring(properties.policyAssignmentScope),
  policyAssignmentName = tostring(properties.policyAssignmentName),
  policyDefinitionId = tostring(properties.policyDefinitionId),
  policyDefinitionReferenceId = tostring(properties.policyDefinitionReferenceId),
  stateWeight = iff(complianceState == 'NonCompliant', int(300), iff(complianceState == 'Compliant', int(200), iff(complianceState == 'Conflict', int(100), iff(complianceState == 'Exempt', int(50), int(0)))))
| where tolower(policyAssignmentId) in ($assignmentResourceId)
| summarize max(stateWeight) by resourceId, policyAssignmentId, policyAssignmentScope, policyAssignmentName, subscriptionId
| summarize counts = count() by policyAssignmentId, policyAssignmentScope, max_stateWeight, policyAssignmentName, subscriptionId
| summarize overallStateWeight = max(max_stateWeight),
nonCompliantCount = sumif(counts, max_stateWeight == 300),
compliantCount = sumif(counts, max_stateWeight == 200),
conflictCount = sumif(counts, max_stateWeight == 100),
exemptCount = sumif(counts, max_stateWeight == 50) by policyAssignmentId, policyAssignmentScope, policyAssignmentName, subscriptionId
| extend totalResources = todouble(nonCompliantCount + compliantCount + conflictCount + exemptCount)
| extend compliancePercentage = round(iff(totalResources == 0, todouble(100), 100 * todouble(compliantCount + exemptCount) / totalResources), 0)
| project policyAssignmentName, policyAssignmentId, scope = policyAssignmentScope, subscriptionId,
complianceState = iff(overallStateWeight == 300, 'noncompliant', iff(overallStateWeight == 200, 'compliant', iff(overallStateWeight == 100, 'conflict', iff(overallStateWeight == 50, 'exempt', 'notstarted')))),
compliancePercentage,
compliantCount,
nonCompliantCount,
conflictCount,
exemptCount
"@

  $assignmentCompliance = invokeARGQueryREST -ScopeType 'managementGroup' -Scope $ManagementGroupName -Query $assignmentComplianceQuery -token $Token

  #Get all policy initiatives and definitions
  $initiatives = @()
  if ($initiativeResourceIds.count -gt 0) {
    $arrAssignedInitiativeResourceId = @()
    foreach ($item in $initiativeResourceIds) {
      $arrAssignedInitiativeResourceId += "'$item'"
    }
    $assignedInitiativeResourceId = $arrAssignedInitiativeResourceId -join ','
    $assignedInitiativeResourceId = $assignedInitiativeResourceId.ToLower()
    $assignedInitiativeQuery = "policyresources | where id in~ ($assignedInitiativeResourceId) | extend isInUse = 'true'"
    $initiatives += invokeARGQueryREST -ScopeType 'tenant' -Query $assignedInitiativeQuery -token $Token
  }

  #get all the member policy definitions and used built-in policy metadata from all the assigned initiatives
  foreach ($initiative in $initiatives) {
    #get all the built-in policy metadata used by the initiative
    foreach ($policyDefinitionGroup in $initiative.properties.policyDefinitionGroups) {
      if ($policyDefinitionGroup.additionalMetadataId -match $policyMetadataResourceIdRegex) {
        $policyMetadataId = $policyDefinitionGroup.additionalMetadataId.tolower()
        if ($policyMetadataResourceIds -notcontains $policyMetadataId) {
          $policyMetadataResourceIds += $policyMetadataId
        }
      }

    }
    #get all the policy definitions used by the initiative
    foreach ($policy in $initiative.properties.policyDefinitions) {
      $defId = $policy.policyDefinitionId.tolower()
      if ($definitionResourceIds -notcontains $defId) {
        $definitionResourceIds += $defId
      }
    }
  }

  #Find unassigned custom initiatives
  $arrInitiativeResourceIds = @()
  foreach ($item in $initiativeResourceIds) {
    $arrInitiativeResourceIds += "'$item'"
  }
  $strInitiativeResourceId = $arrInitiativeResourceIds -join ','
  $strInitiativeResourceId = $strInitiativeResourceId.ToLower()
  $unassignedCustomInitiativesQuery = "policyresources | where type =~ 'microsoft.authorization/policysetdefinitions'| where properties.policyType =~ 'custom'| where id !in~ ($strInitiativeResourceId) | extend isInUse = 'false'"

  $unassignedCustomInitiatives = invokeARGQueryREST -scopeType 'managementGroup' -scope $ManagementGroupName -query $unassignedCustomInitiativesQuery -token $Token

  #get all the built-in member policy definitions and used built-in policy metadata from all the unassigned initiatives
  foreach ($initiative in $unassignedCustomInitiatives) {
    #get all the built-in policy metadata used by the initiative
    foreach ($policyDefinitionGroup in $initiative.properties.policyDefinitionGroups) {
      if ($policyDefinitionGroup.additionalMetadataId -match $policyMetadataResourceIdRegex) {
        $policyMetadataId = $policyDefinitionGroup.additionalMetadataId.tolower()
        if ($policyMetadataResourceIds -notcontains $policyMetadataId) {
          $policyMetadataResourceIds += $policyMetadataId
        }
      }

    }
    #get all the built-in policy definitions used by the initiative, custom policy definitions should already been captured
    foreach ($policy in $initiative.properties.policyDefinitions) {
      $defId = $policy.policyDefinitionId.tolower()
      if ($builtInDefinitionInUnassignedInitiativeResourceIds -notcontains $defId -and $defId -match $builtInDefinitionResourceIdRegex) {
        $builtInDefinitionInUnassignedInitiativeResourceIds += $defId
      }
    }
  }
  #get policy metadata
  if ($policyMetadataResourceIds.count -gt 0) {
    $arrInUsePolicyMetadataResourceIds = @()
    foreach ($item in $policyMetadataResourceIds) {
      $arrInUsePolicyMetadataResourceIds += "'$item'"
    }
    $inUsePolicyMetadataResourceId = $arrInUsePolicyMetadataResourceIds -join ','
    $inUsePolicyMetadataResourceId = $inUsePolicyMetadataResourceId.ToLower()
    $inUsePolicyMetadataQuery = "policyresources | where tolower(id) in ($inUsePolicyMetadataResourceId) | extend isInUse = 'true', framework = 'tbd'"
    $policyMetadata = invokeARGQueryREST -scopeType tenant -query $inUsePolicyMetadataQuery -token $Token
  } else {
    $policyMetadata = @()
  }
  #additional built-in policy metadata
  if ($additionalBuiltInPolicyMetadataConfig) {
    foreach ($config in $additionalBuiltInPolicyMetadataConfig) {
      $additionalPolicyMetadataQuery = "policyresources | where type =~ 'microsoft.policyinsights/policymetadata' | where name matches regex @'$($config.policyMetadataNameRegex)' | extend IsInUse = 'false', framework = '$($config.framework)'"
      $additionalPolicyMetadata = invokeARGQueryREST -scopeType 'tenant' -query $additionalPolicyMetadataQuery -token $Token

      #Add result to $policyMetadata if it's not already present
      $additionalPolicyMetadataCount = 0
      foreach ($item in $additionalPolicyMetadata) {
        if (-not $($policyMetadata | where-object { $_.id -ieq $item.id })) {
          $policyMetadata += $item
          $additionalPolicyMetadataCount ++
        }
      }
    }
  }
  #try to determine the framework for in-use policy metadata using the regex in additionalBuiltInPolicyMetadataConfig, if not match, set framework to 'tbd'
  foreach ($metadata in $($policyMetadata | where-object { $_.isInUse -eq 'true' })) {
    $matchedFramework = $null
    if ($additionalBuiltInPolicyMetadataConfig) {
      foreach ($config in $additionalBuiltInPolicyMetadataConfig) {
        if ($metadata.name -imatch $config.policyMetadataNameRegex) {
          $matchedFramework = $config.framework
          break
        }
      }
    }
    if ($matchedFramework) {
      $metadata.framework = $matchedFramework
    } else {
      $metadata.framework = 'Unknown'
    }
  }
  #get policy definitions
  if ($definitionResourceIds.count -gt 0) {
    $definitions = @()
    $arrAssignedDefinitionsResourceId = @()
    foreach ($item in $definitionResourceIds) {
      $arrAssignedDefinitionsResourceId += "'$item'"
    }
    $assignedDefinitionResourceId = $arrAssignedDefinitionsResourceId -join ','
    $assignedDefinitionResourceId = $assignedDefinitionResourceId.ToLower()
    $assignedDefinitionsQuery = "policyresources | where id in~ ($assignedDefinitionResourceId) | extend isInUse = 'true'"
    $definitions += invokeARGQueryREST -scopeType 'tenant' -query $assignedDefinitionsQuery -token $Token
  } else {
    $definitions = @()
  }

  if ($builtInDefinitionInUnassignedInitiativeResourceIds.count -gt 0) {
    $builtInDefinitionInUnassignedInitiative = getPolicyResources -ResourceIds $builtInDefinitionInUnassignedInitiativeResourceIds -Token $Token
  } else {
    $builtInDefinitionInUnassignedInitiative = @()
  }
  #Find unassigned custom definitions
  $arrDefinitionResourceIds = @()
  foreach ($item in $definitionResourceIds) {
    $arrDefinitionResourceIds += "'$item'"
  }
  $strDefinitionResourceId = $arrDefinitionResourceIds -join ','
  $strDefinitionResourceId = $strDefinitionResourceId.ToLower()
  $unassignedCustomDefinitionsQuery = "policyresources | where type =~ 'microsoft.authorization/policydefinitions'| where properties.policyType =~ 'custom'| where id !in~ ($strDefinitionResourceId) | extend isInUse = 'false'"

  $unassignedCustomDefinitions = invokeARGQueryREST -scopeType 'managementGroup' -scope $ManagementGroupName -query $unassignedCustomDefinitionsQuery -token $Token

  #find policy exemptions created for the assignments
  $policyExemptions = getPolicyExemptions -policyAssignmentResourceIds $assignments.id -token $Token

  #get compliance percentage for each subscription
  $subscriptionComplianceQuery = @"
PolicyResources
| where type =~ 'Microsoft.PolicyInsights/PolicyStates'
| extend complianceState = tostring(properties.complianceState)
| extend
  resourceId = tostring(properties.resourceId),
  stateWeight = iff(complianceState == 'NonCompliant', int(300), iff(complianceState == 'Compliant', int(200), iff(complianceState == 'Conflict', int(100), iff(complianceState == 'Exempt', int(50), int(0)))))
| summarize max(stateWeight) by resourceId, subscriptionId
| summarize counts = count() by subscriptionId, max_stateWeight
| summarize overallStateWeight = max(max_stateWeight),
nonCompliantCount = sumif(counts, max_stateWeight == 300),
compliantCount = sumif(counts, max_stateWeight == 200),
conflictCount = sumif(counts, max_stateWeight == 100),
exemptCount = sumif(counts, max_stateWeight == 50) by subscriptionId
| extend totalResources = todouble(nonCompliantCount + compliantCount + conflictCount + exemptCount)
| extend compliancePercentage = round(iff(totalResources == 0, todouble(100), 100 * todouble(compliantCount + exemptCount) / totalResources), 0)
| project subscriptionId,
complianceState = iff(overallStateWeight == 300, 'noncompliant', iff(overallStateWeight == 200, 'compliant', iff(overallStateWeight == 100, 'conflict', iff(overallStateWeight == 50, 'exempt', 'notstarted')))),
compliancePercentage, compliantCount, nonCompliantCount, conflictCount, exemptCount
"@

  $subscriptionComplianceSummary = invokeARGQueryREST -scopeType 'managementGroup' -scope $ManagementGroupName -query $subscriptionComplianceQuery -token $Token

  #get compliance summary by initiative policyDefinitionGroupName
  $complianceSummaryByInitiativePolicyDefinitionGroupQuery = @"
PolicyResources
| where type =~ 'Microsoft.PolicyInsights/PolicyStates'
| extend complianceState = tostring(properties.complianceState)
| mv-expand policyDefinitionGroupName = properties.policyDefinitionGroupNames
| extend
  resourceId = tostring(properties.resourceId),
  resourceType = tolower(tostring(properties.resourceType)),
  policyAssignmentId = tostring(properties.policyAssignmentId),
  policyDefinitionId = tostring(properties.policyDefinitionId),
  policyDefinitionReferenceId = tostring(properties.policyDefinitionReferenceId),
  stateWeight = iff(complianceState == 'NonCompliant', int(300), iff(complianceState == 'Compliant', int(200), iff(complianceState == 'Conflict', int(100), iff(complianceState == 'Exempt', int(50), int(0)))))
| where policyAssignmentId in~ ($assignmentResourceId)
| summarize max(stateWeight) by tostring(policyDefinitionGroupName), policyDefinitionReferenceId, policyDefinitionId, policyAssignmentId, subscriptionId
| summarize counts = count() by tostring(policyDefinitionGroupName), policyDefinitionReferenceId, policyDefinitionId, policyAssignmentId, max_stateWeight, subscriptionId
| summarize nonCompliantCount = sumif(counts, max_stateWeight == 300), compliantCount = sumif(counts, max_stateWeight == 200),
conflictCount = sumif(counts, max_stateWeight == 100),
exemptCount = sumif(counts, max_stateWeight == 50) by tostring(policyDefinitionGroupName), policyDefinitionReferenceId, policyDefinitionId, policyAssignmentId, subscriptionId
| extend totalResources = todouble(nonCompliantCount + compliantCount + conflictCount + exemptCount)
| project policyDefinitionGroupName, policyDefinitionReferenceId, policyDefinitionId, policyAssignmentId, subscriptionId, compliantCount, nonCompliantCount, conflictCount, exemptCount
"@


  $complianceSummaryByPolicyDefinitionGroup = invokeARGQueryREST  -scopeType 'managementGroup' -scope $ManagementGroupName -query $complianceSummaryByInitiativePolicyDefinitionGroupQuery -token $Token
  $allInitiatives = $($initiatives; $unassignedCustomInitiatives)
  $allDefinitions = $($definitions; $unassignedCustomDefinitions)
  #look up policy metadata resource id for each item in $complianceSummaryByPolicyDefinitionGroup
  foreach ($item in $complianceSummaryByPolicyDefinitionGroup) {
    if ($item.policyDefinitionGroupName.length -gt 0) {
      $policyMetadataId = getPolicyMetadataResourceId -policyMetadataName $item.policyDefinitionGroupName -policyAssignmentId $item.policyAssignmentId -assignments $assignments -initiatives $allInitiatives
    } else {
      $policyMetadataId = $null
    }

    $item | Add-Member -MemberType NoteProperty -Name policyMetadataId -Value $policyMetadataId
  }
  $policyResources = @{
    assignments                                   = $assignments
    initiatives                                   = $allInitiatives
    definitions                                   = $allDefinitions
    exemptions                                    = $policyExemptions
    policyMetadata                                = $policyMetadata
    #unassignedCustomInitiatives = $unassignedCustomInitiatives
    #unassignedCustomDefinitions = $unassignedCustomDefinitions
    builtInDefinitionInUnAssignedCustomInitiative = $builtInDefinitionInUnassignedInitiative
    assignmentCompliance                          = $assignmentCompliance
    subscriptionComplianceSummary                 = $subscriptionComplianceSummary
    complianceSummaryByPolicyDefinitionGroup      = $complianceSummaryByPolicyDefinitionGroup
  }
  $policyResources
}

#function to get the managed identity principal IDs for assignments
function getPolicyAssignmentManagedIdentityPrincipalIds {
  [CmdletBinding()]
  [OutputType([array])]
  param (
    [parameter(Mandatory = $true)]
    [System.Object[]]
    $assignments
  )

  # Get all managed identity principal IDs from policy assignments
  $assignmentIdentities = @()
  foreach ($assignment in $assignments) {
    if ($assignment.identity) {
      $assignmentIdentity = @{
        assignmentId = $assignment.id
      }
      if ($assignment.identity -and $assignment.identity.type -ieq 'SystemAssigned') {
        $assignmentIdentity.Add('systemAssignedPrincipalId', $assignment.identity.principalId)
      } elseif ($assignment.identity.type -ieq 'UserAssigned') {
        $userAssignedIdentities = ConvertToOrderedHashtable -inputObject $assignment.identity.userAssignedIdentities
        $userAssignedIdentityResourceId = $userAssignedIdentities.Keys
        $usmi = @()
        foreach ($id in $userAssignedIdentityResourceId) {
          $usmi += @{
            resourceId  = $id
            principalId = $userAssignedIdentities[$id].principalId
          }
        }
        $assignmentIdentity.Add('userAssignedIdentities', $usmi)
      }
      $assignmentIdentities += $assignmentIdentity
    }
  }
  , $assignmentIdentities
}

#function to get policy exemptions for a list of policy assignment IDs using Azure Resource Graph
function getPolicyExemptions {
  [CmdletBinding()]
  [OutputType([System.Object[]])]
  param (
    [parameter(Mandatory = $true)][string[]]$policyAssignmentResourceIds,

    [parameter(Mandatory = $true, HelpMessage = "The Azure OAuth token for accessing 'https://management.azure.com/'. This is required to invoke the Azure Resource Graph query via it's REST API.")]
    [ValidateNotNullOrEmpty()]
    [string]$Token
  )
  $arrAssignmentId = @()
  foreach ($item in $policyAssignmentResourceIds) {
    $arrAssignmentId += "'$item'"
  }
  $strAssignmentId = $arrAssignmentId -join ','
  $strAssignmentId = $strAssignmentId.ToLower()
  $query = @"
policyresources
| where type =~ 'microsoft.authorization/policyexemptions'
| extend policyAssignmentId = properties.policyAssignmentId
| where tolower(policyAssignmentId) in ($strAssignmentId)
| project id, name, displayName = properties.displayName, subscriptionId, resourceGroup, policyAssignmentId, policyDefinitionReferenceIds = properties.policyDefinitionReferenceIds, exemptionCategory = properties.exemptionCategory, description = properties.description, expiresOn = properties.expiresOn, metadata = properties.metadata, definitionVersion = properties.definitionVersion, resourceSelectors = properties.resourceSelectors
"@

  $searchParams = @{
    scopeType = 'tenant'
    query     = $query
    token     = $Token
  }
  $result = invokeARGQueryREST @searchParams
  if ($result) {
    Write-Verbose "[$(getCurrentUTCString)]: Found policy exemptions in the $($searchParams.scopeType) scope"
  } else {
    Write-Verbose "[$(getCurrentUTCString)]: No policy exemptions found in the $($searchParams.scopeType) scope"
    $result = @()
  }
  return , $result
}

#function to get role assignments using Azure Resource Graph
function getRoleAssignments {
  [CmdletBinding()]
  [OutputType([System.Object[]])]
  param (
    [parameter(Mandatory = $true)][string[]]$principalIds,

    [parameter(Mandatory = $true, HelpMessage = "The Azure OAuth token for accessing 'https://management.azure.com/'. This is required to invoke the Azure Resource Graph query via it's REST API.")]
    [ValidateNotNullOrEmpty()]
    [string]$Token
  )
  $arrPrincipalId = @()
  foreach ($item in $principalIds) {
    $arrPrincipalId += "'$item'"
  }
  $strPrincipalId = $arrPrincipalId -join ','
  $strPrincipalId = $strPrincipalId.ToLower()
  $query = @"
authorizationresources
| where type =~ "microsoft.authorization/roleassignments"
| extend principalId = properties.principalId, principalType = properties.principalType, scope = properties.scope, roleDefinitionId = properties.roleDefinitionId
| where tolower(principalId) in ($strPrincipalId)
| project id, principalId, principalType, scope, roleDefinitionId
"@

  $searchParams = @{
    scopeType = 'tenant'
    query     = $query
    token     = $Token
  }
  $result = invokeARGQueryREST @searchParams
  if (!$result) {
    $result = @()
  }
  return , $result
}

#function to get role definitions using Azure Resource Graph
function getRoleDefinitions {
  [CmdletBinding()]
  [OutputType([System.Object[]])]
  param (
    [parameter(Mandatory = $true)][string[]]$resourceIds,

    [parameter(Mandatory = $true, HelpMessage = "The Azure OAuth token for accessing 'https://management.azure.com/'. This is required to invoke the Azure Resource Graph query via it's REST API.")]
    [ValidateNotNullOrEmpty()]
    [string]$Token
  )
  $arrResourceId = @()
  foreach ($item in $resourceIds) {
    $arrResourceId += "'$item'"
  }
  $strResourceId = $arrResourceId -join ','
  $strResourceId = $strResourceId.ToLower()
  $query = @"
authorizationresources
| where type == "microsoft.authorization/roledefinitions"
| extend roleName = properties.roleName, description = properties.description, assignableScopes = properties.assignableScopes, type = properties.type, isServiceRole = properties.isServiceRole
| where tolower(id) in ($strResourceId)
| project id, name, roleName, description, assignableScopes, type, isServiceRole
"@


  $searchParams = @{
    scopeType = 'tenant'
    query     = $query
    token     = $Token
  }
  $result = invokeARGQueryREST @searchParams
  if (!$result) {
    $result = @()
  }
  return , $result
}

#function to get management group hierarchy
function getManagementGroupHierarchy {
  [CmdletBinding()]
  [OutputType([System.Object[]])]
  param (
    [parameter(Mandatory = $true)][string]$ManagementGroupName,

    [parameter(Mandatory = $true, HelpMessage = "The Azure OAuth token for accessing 'https://management.azure.com/'. This is required to invoke the Azure Resource Graph query via it's REST API.")]
    [ValidateNotNullOrEmpty()]
    [string]$Token
  )
  $Query = @"
resourcecontainers
| where type =~ 'microsoft.management/managementgroups'
| extend parentId = properties.details.parent.id, parentName = properties.details.parent.name, parentDisplayName = properties.details.parent.displayName, managementGroupAncestors = tostring(properties.details.managementGroupAncestorsChain)
| extend ancestors = extract_all(@'\"name\"\:\"([^"]+)\"}', managementGroupAncestors)
| extend strAncestors = tostring(ancestors)
| where strAncestors contains '"$ManagementGroupName"' or name =~ '$ManagementGroupName'
| extend tier = coalesce(array_length(ancestors), 0)+1
| project id, name, displayName = properties.displayName, parentId, parentName, parentDisplayName, ancestors, tier
| sort by tier asc
"@

  $result = invokeARGQueryREST -scopeType 'tenant' -query $Query -token $Token
  if (!$result) {
    $result = @()
  }
  return , $result
}

#function to get subscriptions under management group
function getSubscriptionsUnderManagementGroup {
  [CmdletBinding()]
  [OutputType([System.Object[]])]
  param (
    [parameter(Mandatory = $true)][string]$ManagementGroupName,

    [parameter(Mandatory = $true, HelpMessage = "The Azure OAuth token for accessing 'https://management.azure.com/'. This is required to invoke the Azure Resource Graph query via it's REST API.")]
    [ValidateNotNullOrEmpty()]
    [string]$Token
  )
  $Query = @"
ResourceContainers
| where type =~ 'microsoft.resources/subscriptions'
| extend mgParent = properties.managementGroupAncestorsChain
| mv-expand with_itemindex=MGHierarchy mgParent
| where MGHierarchy == 0
| project id, subscriptionId, name, tenantId, parentMgName = mgParent.name, tags, managementGroupAncestorsChain = properties.managementGroupAncestorsChain, quotaId = properties.subscriptionPolicies.quotaId, state = properties.state
"@


  $result = invokeARGQueryREST -scopeType 'managementGroup' -scope $ManagementGroupName -query $Query -token $Token
  if (!$result) {
    $result = @()
  }
  return , $result
}

#function to look up policy metadata resource Id by name and policy assignment id
function getPolicyMetadataResourceId {
  [CmdletBinding()]
  [OutputType([string])]
  param (
    [parameter(Mandatory = $true)][string]$policyMetadataName,
    [parameter(Mandatory = $true)][string]$policyAssignmentId,
    [parameter(Mandatory = $true)][object[]]$assignments,
    [parameter(Mandatory = $true)][object[]]$initiatives
  )

  #Get the policy assignment
  $assignment = $assignments | Where-Object { $_.id -ieq $policyAssignmentId }
  if (-not $assignment) {
    Write-Error "[$(getCurrentUTCString)]: Unable to find policy assignment with Id '$policyAssignmentId'."
    return $null
  }

  #Get the policy definition Id from the assignment
  $policyDefinitionId = $assignment.properties.policyDefinitionId
  if (-not $policyDefinitionId) {
    Write-Error "[$(getCurrentUTCString)]: Unable to find policy definition Id from policy assignment with Id '$policyAssignmentId'."
    return $null
  }
  #Check if the policy definition is an initiative
  if ($policyDefinitionId -notmatch '(?im)\/providers\/microsoft\.authorization\/policysetdefinitions\/') {
    Write-Verbose "[$(getCurrentUTCString)]: The policy definition Id '$policyDefinitionId' from policy assignment with Id '$policyAssignmentId' is not a policy initiative."
    return $null
  }

  #Get the initiative
  $initiative = $initiatives | Where-Object { $_.id -ieq $policyDefinitionId }
  if (-not $initiative) {
    Write-Error "[$(getCurrentUTCString)]: Unable to find policy initiative with Id '$policyDefinitionId'."
    return $null
  }

  #Get policy definition group
  $pdg = $initiative.properties.policyDefinitionGroups | Where-Object { $_.name -ieq $policyMetadataName }
  if (-not $pdg) {
    Write-Error "[$(getCurrentUTCString)]: Unable to find policy definition group with name '$policyMetadataName' in initiative with Id '$policyDefinitionId'."
    return $null
  }

  #check if additionalMetadataId exists
  if (-not $pdg.additionalMetadataId) {
    Write-Verbose "[$(getCurrentUTCString)]: No additionalMetadataId found in policy definition group with name '$policyMetadataName' in initiative with Id '$policyDefinitionId'."
    return $null
  } else {
    return $pdg.additionalMetadataId
  }
}

#function to sign the environment discovery file to ensure integrity and prevent manual modification
function signContent {
  [CmdletBinding()]
  [OutputType([System.Collections.Specialized.OrderedDictionary])]
  param(
    [System.Collections.Specialized.OrderedDictionary]$inputContent
  )
  $inputJson = $inputContent | ConvertTo-Json -Depth 100
  $inputFileBytes = [System.Text.Encoding]::UTF8.GetBytes($inputJson)
  # Load private key
  $codeSignPrivateKey = '<RSAKeyValue><Modulus>u27QW4gRFxuf34cydg+kIyAqpBfzrMfeqZmu6YhK5aacurte+xPEhLD0SUwHgfve+kb2l7+QKp9dGlU+w/obT9b6hw1IBtr5amcO2QRxWbG1IAtq1cgLkyeL9/rWw3nvsTTO4vDnLqmGzaOZl+0ZueU5lfArfb2cpj03B+MUBEHcwwziQfOQN6z21lezM/NVVDvKswTIeJlOipRwBeD85Wf6T5egMibLri9WVsmeDCq0fN3WPBZOtaeO2xh8j1U4HQN/jKbOmp0JdhyLgjtJKLpCRwxzZKz5MR25U3NN41K+w7JXirXEZ8f3E7PywjAC+GawrVqm9qvVOj8Pw1hEzw==</Modulus><Exponent>AQAB</Exponent><P>5FpIZw3UOYh1bzLdtAmj2j/yD6/AgEWY63WkLT2iXcuApysNXpn8O0QD6R9/IP7kT6Sygy1iVtgxtwy6T6Pfbb0Je8XypKH4y5u+5chKWWNEF3zMyJNm/WTuRdFLp8zVAhvZKgDr8U7EXNMA/bRjjzaDXV5sYuIpuBNR632i6rU=</P><Q>0iA8Lbj6j0xc5u2slmFGtUhhHmeIC2kwWy+TvTUM7DXJGlumOs2VWi7A5NDJVErJq3J9tEx1nE/JOR3dmCRYo5FjQpi25ov9AJ03UjMiYU5RdUYbedhOsehA40TFrnijTd7BHSt3uf+UyfXldKwPC2pyR+tJfefFvmwy87iTb/M=</Q><DP>OiENa0Zm7tpg4pS1QT5u+oDqsrmPrFeLLO0+hNG6wluqfWOFPL7AKaoPwMlmyFR6dBe95YypCuhwB2PzDZDusHW9s0dZlDUzK0mTV9UbsBOH4t6/FpQHVJvb1+QGeUm4M5g5OOsRJNVjQSnph6vi6DL9T8hYmPdvnU81vFe4ID0=</DP><DQ>EKjVnJpp4yqewwBdCp8zx/OxH4P92UnBt7kR8ZJfL+sjwp85krfx+3BxgrK7A5/aVWmCZeUZ/galYY3on8Nh87X3NLR+69B1grL0S+QZ+bUH6FBipUptEtXjLyGouqh96hKbJQDpQSnuVgTU6Gcr2i/aV7Fj5kOV/f7+GyyrI9k=</DQ><InverseQ>ZVCSvqYk4Vwz9yeCEVDb887e5qufb8zFrR0SCjsvLKZSz+nxpmA7++CHED+2W6Ko3pFvQd6iucITVJU5Ri/wXe+wbo1DcrJIPl/6rdkAZ3WpYcNgNOX/Rag+f8yKbU+07Op8JWRRWww4ioK71bcxBJeYkuTLxt+mjIDyjWYN09g=</InverseQ><D>Ot3P1J+EdpR3frWwdIHvFBUbL1RSdLaFSRD6bWAf3caZPNByQTrxIpeM8aROS1W2rzYwZnKErc5FaGrTfquDfmzPXDEgqP6HDRL3B9OsYfY9I6ZSVHgyonHuNOD0KIqYbDhgrcfpY69cpiM2rw+JX+gNUPkQP7qYg2GAchqwg7E+FNOyOfXQJUzy4oZz3YfCz4rr/MF++gc7I58OWK9oNtsDDJ0JQQDh+oE3+7GfAo43kCaaUPlSuck4QcjwcSTHtmSEoZQEZBCWXCk4XINtHSBLXggb1WWdMFgRtyWYplwfJUtIgMcaGPHDu2qVYp0hMy1O3Wm3z/8NZWnqq7i2GQ==</D></RSAKeyValue>'
  $rsa = [System.Security.Cryptography.RSACryptoServiceProvider]::new()
  $rsa.FromXmlString($codeSignPrivateKey)  # Use FromXmlString for XML format

  $signature = $rsa.SignData($inputFileBytes,
    [System.Security.Cryptography.HashAlgorithmName]::SHA256,
    [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)

  # Cleanup
  $rsa.Dispose()

  # Export signature as Base64
  $signature = [Convert]::ToBase64String($signature)

  # Create output object with signature embedded
  $signedOutput = [ordered]@{
    signature = $signature
    data      = $inputContent
  }
  $signedOutput
}