modules/Invoke-Powerpipe.ps1

#Requires -Version 7.4
<#
.SYNOPSIS
    Wrapper for Powerpipe compliance control packs.
.DESCRIPTION
    Runs a Powerpipe benchmark and returns a v1 envelope with flattened control
    findings for downstream normalization. Never throws.
.PARAMETER SubscriptionId
    Azure subscription ID used for entity fallback IDs in normalization.
.PARAMETER Benchmark
    Powerpipe benchmark selector. Default: all.
#>

[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
param (
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string] $SubscriptionId,

    [string] $Benchmark = 'all'
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$sanitizePath = Join-Path $PSScriptRoot 'shared' 'Sanitize.ps1'
if (Test-Path $sanitizePath) { . $sanitizePath }
$missingToolPath = Join-Path $PSScriptRoot 'shared' 'MissingTool.ps1'
if (Test-Path $missingToolPath) { . $missingToolPath }
if (-not (Get-Command Remove-Credentials -ErrorAction SilentlyContinue)) {
    function Remove-Credentials { param([string]$Text) return $Text }
}

$errorsPath = Join-Path $PSScriptRoot 'shared' 'Errors.ps1'
if (Test-Path $errorsPath) { . $errorsPath }

$envelopePath = Join-Path $PSScriptRoot 'shared' 'New-WrapperEnvelope.ps1'
if (Test-Path $envelopePath) { . $envelopePath }
if (-not (Get-Command New-WrapperEnvelope -ErrorAction SilentlyContinue)) { function New-WrapperEnvelope { param([string]$Source,[string]$Status='Failed',[string]$Message='',[object[]]$FindingErrors=@()) return [PSCustomObject]@{ Source=$Source; SchemaVersion='1.0'; Status=$Status; Message=$Message; Findings=@(); Errors=@($FindingErrors) } } }
if (-not (Get-Command New-FindingError -ErrorAction SilentlyContinue)) {
    function New-FindingError { param([string]$Source,[string]$Category,[string]$Reason,[string]$Remediation,[string]$Details) return [pscustomobject]@{ Source=$Source; Category=$Category; Reason=$Reason; Remediation=$Remediation; Details=$Details } }
}
if (-not (Get-Command Format-FindingErrorMessage -ErrorAction SilentlyContinue)) {
    function Format-FindingErrorMessage {
        param([Parameter(Mandatory)]$FindingError)
        $line = "[{0}] {1}: {2}" -f $FindingError.Source, $FindingError.Category, $FindingError.Reason
        if ($FindingError.Remediation) { $line += " Action: $($FindingError.Remediation)" }
        return $line
    }
}
# Bootstrap Invoke-WithTimeout for CLI timeout protection
$cliTimeoutPath = Join-Path $PSScriptRoot 'shared' 'CliTimeout.ps1'
if (Test-Path $cliTimeoutPath) { . $cliTimeoutPath }

function Test-PowerpipeInstalled {
    return $null -ne (Get-Command powerpipe -ErrorAction SilentlyContinue)
}

function Get-Prop {
    param([object]$Obj, [string[]]$Names, [object]$Default = $null)
    if ($null -eq $Obj) { return $Default }
    foreach ($name in $Names) {
        $prop = $Obj.PSObject.Properties[$name]
        if ($null -ne $prop -and $null -ne $prop.Value) { return $prop.Value }
    }
    return $Default
}

function Flatten-PowerpipeControls {
    param (
        [Parameter(Mandatory)]
        [object] $Node,
        [Parameter(Mandatory)]
        [System.Collections.Generic.List[object]] $Findings
    )

    if ($null -eq $Node) { return }
    if ($Node -is [array]) {
        foreach ($item in $Node) {
            Flatten-PowerpipeControls -Node $item -Findings $Findings
        }
        return
    }

    $controls = Get-Prop -Obj $Node -Names @('controls', 'Controls') -Default @()
    foreach ($control in @($controls)) {
        if ($null -eq $control) { continue }
        $status = [string](Get-Prop -Obj $control -Names @('status', 'Status') -Default '')
        $controlId = [string](Get-Prop -Obj $control -Names @('control_id', 'controlId', 'id', 'name', 'key') -Default ([guid]::NewGuid().ToString()))
        $title = [string](Get-Prop -Obj $control -Names @('title', 'Title', 'display_name', 'description') -Default $controlId)
        $detail = [string](Get-Prop -Obj $control -Names @('description', 'Description', 'reason', 'summary') -Default '')
        $severity = [string](Get-Prop -Obj $control -Names @('severity', 'Severity', 'level') -Default 'Medium')
        $resourceId = [string](Get-Prop -Obj $control -Names @('resource_id', 'resourceId', 'ResourceId') -Default '')
        $learnMore = [string](Get-Prop -Obj $control -Names @('documentation_url', 'DocumentationUrl', 'doc_url', 'LearnMoreUrl') -Default '')
        $remediation = [string](Get-Prop -Obj $control -Names @('remediation_doc', 'remediation', 'Remediation') -Default '')
        $tags = Get-Prop -Obj $control -Names @('tags', 'Tags') -Default @{}
        $evidence = Get-Prop -Obj $control -Names @('evidence_uris', 'EvidenceUris') -Default @()
        $rows = Get-Prop -Obj $control -Names @('rows', 'Rows') -Default @()
        if ((-not $evidence -or @($evidence).Count -eq 0) -and $rows) {
            $rowUris = [System.Collections.Generic.List[string]]::new()
            foreach ($row in @($rows)) {
                $uri = [string](Get-Prop -Obj $row -Names @('url', 'uri', 'link', 'deep_link') -Default '')
                if (-not [string]::IsNullOrWhiteSpace($uri)) { $rowUris.Add($uri) | Out-Null }
            }
            $evidence = @($rowUris)
        }

        $Findings.Add([pscustomobject]@{
                Id                 = "powerpipe/$controlId"
                Source             = 'powerpipe'
                ControlId          = $controlId
                Title              = $title
                Status             = $status
                Severity           = $severity
                Category           = [string](Get-Prop -Obj $control -Names @('category', 'Category', 'group') -Default '')
                Detail             = $detail
                Remediation        = $remediation
                ResourceId         = $resourceId
                LearnMoreUrl       = $learnMore
                Tags               = $tags
                EvidenceUris       = @($evidence)
                DeepLinkUrl        = [string](Get-Prop -Obj $control -Names @('deep_link_url', 'DeepLinkUrl') -Default $learnMore)
                RemediationSnippets = @()
                BaselineTags       = @()
                ToolVersion        = ''
            }) | Out-Null
    }

    foreach ($childName in @('children', 'benchmarks', 'groups', 'items')) {
        $children = Get-Prop -Obj $Node -Names @($childName) -Default @()
        foreach ($child in @($children)) {
            Flatten-PowerpipeControls -Node $child -Findings $Findings
        }
    }
}

if (-not (Test-PowerpipeInstalled)) {
    Write-MissingToolNotice -Tool 'powerpipe' -Message 'powerpipe is not installed. Skipping Powerpipe scan. Install from https://powerpipe.io'
    return [PSCustomObject]@{
        Source        = 'powerpipe'
        SchemaVersion = '1.0'
        Status        = 'Skipped'
        Message       = 'powerpipe not installed'
        ToolVersion   = ''
        Findings      = @()
        Errors   = @()
    }
}

try {
    $versionOut = (& powerpipe --version 2>&1 | Select-Object -First 1)
    $toolVersion = if ($versionOut) { [string]$versionOut } else { '' }

    $ppExec = Invoke-WithTimeout -Command 'powerpipe' -Arguments @('benchmark', 'run', $Benchmark, '--output', 'json') -TimeoutSec 300
    $rawOutput = if ($ppExec.PSObject.Properties['Stdout'] -and $ppExec.Stdout) { $ppExec.Stdout } else { $ppExec.Output }
    if ([int]$ppExec.ExitCode -eq -1) {
        throw (Format-FindingErrorMessage (New-FindingError `
            -Source 'wrapper:powerpipe' `
            -Category 'TimeoutExceeded' `
            -Reason "powerpipe benchmark run timed out after 300 seconds." `
            -Remediation 'Inspect powerpipe CLI output; ensure the benchmark mod is installed and credentials configured.' `
            -Details ''))
    }
    if ([int]$ppExec.ExitCode -ne 0) {
        throw (Format-FindingErrorMessage (New-FindingError `
            -Source 'wrapper:powerpipe' `
            -Category 'UnexpectedFailure' `
            -Reason "powerpipe benchmark run failed (exit $([int]$ppExec.ExitCode))." `
            -Remediation 'Inspect powerpipe CLI output; ensure the benchmark mod is installed and credentials configured.' `
            -Details (Remove-Credentials -Text ([string]$rawOutput))))
    }

    $parsed = $rawOutput | ConvertFrom-Json -Depth 100 -ErrorAction Stop
    $findings = [System.Collections.Generic.List[object]]::new()

    if ($parsed.PSObject.Properties['findings'] -and $parsed.findings) {
        foreach ($f in @($parsed.findings)) {
            $findings.Add($f) | Out-Null
        }
    } else {
        Flatten-PowerpipeControls -Node $parsed -Findings $findings
    }

    foreach ($f in @($findings)) {
        if (-not $f.PSObject.Properties['ToolVersion']) {
            $f | Add-Member -NotePropertyName ToolVersion -NotePropertyValue $toolVersion -Force
        } elseif (-not $f.ToolVersion) {
            $f.ToolVersion = $toolVersion
        }
    }

    return [PSCustomObject]@{
        Source        = 'powerpipe'
        SchemaVersion = '1.0'
        Status        = 'Success'
        Message       = ''
        ToolVersion   = $toolVersion
        Subscription  = $SubscriptionId
        Findings      = @($findings)
        Errors   = @()
    }
} catch {
    Write-Warning "powerpipe scan failed: $(Remove-Credentials -Text ([string]$_))"
    return [PSCustomObject]@{
        Source        = 'powerpipe'
        SchemaVersion = '1.0'
        Status        = 'Failed'
        Message       = (Remove-Credentials -Text ([string]$_))
        ToolVersion   = ''
        Findings      = @()
        Errors   = @()
    }
}