scripts/audit-tool-fields.ps1

# Helper for issue #432a — extract wrapper-emitted and normalizer-preserved
# fields per tool, dump JSON for downstream rendering. Audit-first, doc-only.
[CmdletBinding()]
param([string] $RepoRoot = (Split-Path $PSScriptRoot -Parent))

$ErrorActionPreference = 'Stop'
$manifest = Get-Content (Join-Path $RepoRoot 'tools\tool-manifest.json') -Raw | ConvertFrom-Json

$wrapperMap = @{
    'azqr'                     = 'Invoke-Azqr.ps1'
    'kubescape'                = 'Invoke-Kubescape.ps1'
    'kube-bench'               = 'Invoke-KubeBench.ps1'
    'defender-for-cloud'       = 'Invoke-DefenderForCloud.ps1'
    'prowler'                  = 'Invoke-Prowler.ps1'
    'falco'                    = 'Invoke-Falco.ps1'
    'azure-cost'               = 'Invoke-AzureCost.ps1'
    'azure-quota'              = 'Invoke-AzureQuotaReports.ps1'
    'finops'                   = 'Invoke-FinOpsSignals.ps1'
    'appinsights'              = 'Invoke-AppInsights.ps1'
    'loadtesting'              = 'Invoke-AzureLoadTesting.ps1'
    'aks-rightsizing'          = 'Invoke-AksRightsizing.ps1'
    'aks-karpenter-cost'       = 'Invoke-AksKarpenterCost.ps1'
    'psrule'                   = 'Invoke-PSRule.ps1'
    'powerpipe'                = 'Invoke-Powerpipe.ps1'
    'azgovviz'                 = 'Invoke-AzGovViz.ps1'
    'alz-queries'              = 'Invoke-AlzQueries.ps1'
    'wara'                     = 'Invoke-WARA.ps1'
    'maester'                  = 'Invoke-Maester.ps1'
    'scorecard'                = 'Invoke-Scorecard.ps1'
    'gh-actions-billing'       = 'Invoke-GhActionsBilling.ps1'
    'ado-connections'          = 'Invoke-ADOServiceConnections.ps1'
    'ado-pipelines'            = 'Invoke-ADOPipelineSecurity.ps1'
    'ado-consumption'          = 'Invoke-AdoConsumption.ps1'
    'ado-repos-secrets'        = 'Invoke-ADORepoSecrets.ps1'
    'ado-pipeline-correlator'  = 'Invoke-ADOPipelineCorrelator.ps1'
    'identity-correlator'      = 'Invoke-IdentityCorrelator.ps1'
    'identity-graph-expansion' = 'Invoke-IdentityGraphExpansion.ps1'
    'zizmor'                   = 'Invoke-Zizmor.ps1'
    'gitleaks'                 = 'Invoke-Gitleaks.ps1'
    'trivy'                    = 'Invoke-Trivy.ps1'
    'bicep-iac'                = 'Invoke-IaCBicep.ps1'
    'infracost'                = 'Invoke-Infracost.ps1'
    'terraform-iac'            = 'Invoke-IaCTerraform.ps1'
    'sentinel-incidents'       = 'Invoke-SentinelIncidents.ps1'
    'sentinel-coverage'        = 'Invoke-SentinelCoverage.ps1'
    'copilot-triage'           = 'Invoke-CopilotTriage.ps1'
}

$normalizerMap = @{
    'azqr' = 'Normalize-Azqr.ps1'; 'kubescape' = 'Normalize-Kubescape.ps1'
    'kube-bench' = 'Normalize-KubeBench.ps1'; 'defender-for-cloud' = 'Normalize-DefenderForCloud.ps1'
    'prowler' = 'Normalize-Prowler.ps1'; 'falco' = 'Normalize-Falco.ps1'
    'azure-cost' = 'Normalize-AzureCost.ps1'; 'azure-quota' = 'Normalize-AzureQuotaReports.ps1'
    'finops' = 'Normalize-FinOpsSignals.ps1'; 'appinsights' = 'Normalize-AppInsights.ps1'
    'loadtesting' = 'Normalize-AzureLoadTesting.ps1'; 'aks-rightsizing' = 'Normalize-AksRightsizing.ps1'
    'aks-karpenter-cost' = 'Normalize-AksKarpenterCost.ps1'; 'psrule' = 'Normalize-PSRule.ps1'
    'powerpipe' = 'Normalize-Powerpipe.ps1'; 'azgovviz' = 'Normalize-AzGovViz.ps1'
    'alz-queries' = 'Normalize-AlzQueries.ps1'; 'wara' = 'Normalize-WARA.ps1'
    'maester' = 'Normalize-Maester.ps1'; 'scorecard' = 'Normalize-Scorecard.ps1'
    'gh-actions-billing' = 'Normalize-GhActionsBilling.ps1'
    'ado-connections' = 'Normalize-ADOConnections.ps1'; 'ado-pipelines' = 'Normalize-ADOPipelineSecurity.ps1'
    'ado-consumption' = 'Normalize-AdoConsumption.ps1'; 'ado-repos-secrets' = 'Normalize-ADORepoSecrets.ps1'
    'ado-pipeline-correlator' = 'Normalize-ADOPipelineCorrelator.ps1'
    'identity-correlator' = 'Normalize-IdentityCorrelation.ps1'
    'identity-graph-expansion' = 'Normalize-IdentityGraphExpansion.ps1'
    'zizmor' = 'Normalize-Zizmor.ps1'; 'gitleaks' = 'Normalize-Gitleaks.ps1'
    'trivy' = 'Normalize-Trivy.ps1'; 'bicep-iac' = 'Normalize-IaCBicep.ps1'
    'infracost' = 'Normalize-Infracost.ps1'; 'terraform-iac' = 'Normalize-IaCTerraform.ps1'
    'sentinel-incidents' = 'Normalize-SentinelIncidents.ps1'
    'sentinel-coverage' = 'Normalize-SentinelCoverage.ps1'
    'copilot-triage' = $null
}

# FindingRow schema fields (modules/shared/Schema.ps1 — v2.2 additive set).
$schemaFields = @(
    'Id','Source','EntityId','EntityType','Title','RuleId','Compliant','ProvenanceRunId',
    'Category','Severity','Detail','Remediation','ResourceId','LearnMoreUrl','Platform',
    'SubscriptionId','SubscriptionName','ResourceGroup','ManagementGroupPath',
    'Frameworks','Controls','Confidence','EvidenceCount','MissingDimensions',
    'ProvenanceSource','ProvenanceRawRecordRef','ProvenanceTimestamp',
    'Pillar','Impact','Effort','DeepLinkUrl','RemediationSnippets','EvidenceUris',
    'BaselineTags','ScoreDelta','MitreTactics','MitreTechniques','EntityRefs','ToolVersion'
)

function Get-WrapperFields {
    param([string] $Path)
    if (-not $Path -or -not (Test-Path $Path)) { return @() }
    $text = Get-Content $Path -Raw
    $props = New-Object System.Collections.Generic.List[string]
    $rx = [regex]'(?ms)PSCustomObject\s*\]\s*@\{(.*?)\n\s*\}'
    foreach ($m in $rx.Matches($text)) {
        foreach ($line in $m.Groups[1].Value -split "`n") {
            if ($line -match '^\s*([A-Za-z][A-Za-z0-9_]*)\s*=') { $props.Add($matches[1]) }
        }
    }
    $rx2 = [regex]'(?ms)\$(?:row|finding|record|entry|item|out|result|envelope|signal)\w*\s*=\s*@\{(.*?)\n\s*\}'
    foreach ($m in $rx2.Matches($text)) {
        foreach ($line in $m.Groups[1].Value -split "`n") {
            if ($line -match '^\s*([A-Za-z][A-Za-z0-9_]*)\s*=') { $props.Add($matches[1]) }
        }
    }
    return ($props | Sort-Object -Unique)
}

function Get-NormalizerFields {
    param([string] $Path)
    if (-not $Path -or -not (Test-Path $Path)) { return @() }
    $text = Get-Content $Path -Raw
    $fields = New-Object System.Collections.Generic.List[string]
    $rx = [regex]'(?ms)New-FindingRow\b(.*?)(?:^\s*\}|\z)'
    foreach ($m in $rx.Matches($text)) {
        foreach ($p in [regex]::Matches($m.Groups[1].Value, '-([A-Z][A-Za-z0-9]+)\b')) {
            $fields.Add($p.Groups[1].Value)
        }
    }
    $rx2 = [regex]'(?ms)\$\w*[Pp]arams\s*=\s*@\{(.*?)\n\s*\}'
    foreach ($m in $rx2.Matches($text)) {
        foreach ($line in $m.Groups[1].Value -split "`n") {
            if ($line -match '^\s*([A-Z][A-Za-z0-9]+)\s*=') { $fields.Add($matches[1]) }
        }
    }
    return ($fields | Where-Object { $schemaFields -contains $_ } | Sort-Object -Unique)
}

$results = New-Object System.Collections.Generic.List[object]
foreach ($t in $manifest.tools) {
    $w = $wrapperMap[$t.name]
    $n = $normalizerMap[$t.name]
    $wPath = if ($w) { Join-Path $RepoRoot "modules\$w" } else { $null }
    $nPath = if ($n) { Join-Path $RepoRoot "modules\normalizers\$n" } else { $null }
    $wrapperExists = $wPath -and (Test-Path $wPath)
    $normExists = $nPath -and (Test-Path $nPath)
    $wrapperFields = if ($wrapperExists) { @(Get-WrapperFields -Path $wPath) } else { @() }
    $normFields = if ($normExists) { @(Get-NormalizerFields -Path $nPath) } else { @() }
    $missing = @($schemaFields | Where-Object { $_ -notin $normFields })
    $results.Add([pscustomobject]@{
        Tool                   = $t.name
        DisplayName            = $t.displayName
        Provider               = $t.provider
        Scope                  = $t.scope
        Enabled                = $t.enabled
        WrapperFile            = if ($wrapperExists) { (("modules/$w") -replace '\\','/') } else { $null }
        NormalizerFile         = if ($normExists) { "modules/normalizers/$n" } else { $null }
        WrapperFields          = $wrapperFields
        NormalizerSchemaFields = $normFields
        SchemaFieldsMissing    = $missing
    })
}

$out = Join-Path $RepoRoot 'audit-raw.json'
$results | ConvertTo-Json -Depth 6 | Set-Content -Path $out -Encoding UTF8
Write-Host "Wrote $out ($($results.Count) tools)"