Public/Export-GuerrillaJUnit.ps1
|
# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Export-GuerrillaJUnit { <# .SYNOPSIS Exports PSGuerrilla findings as JUnit XML — the format GitHub Actions, Azure DevOps, and GitLab render natively as pass/fail — so PSGuerrilla drops into the same CI pipelines as Maester. .DESCRIPTION Turns any theater's findings (AD / Entra / M365 / Google Workspace) into a JUnit test report: one <testsuite> per category, one <testcase> per check. FAIL -> <failure>; SKIP/ERROR -> <skipped> (Not Assessed); WARN -> passing with output, or <failure type="warning"> when -WarningsAsFailures. Returns the counts so a pipeline can gate on them (exit non-zero on Failures). .PARAMETER Findings Audit findings, e.g. (Invoke-Infiltration -PassThru).Findings. .PARAMETER OutputPath Destination .xml. Default ./PSGuerrilla-results.xml. .PARAMETER SuiteName Top-level <testsuites> name. Default 'PSGuerrilla'. .PARAMETER WarningsAsFailures Treat WARN findings as failures (so CI gates on them too). .EXAMPLE $r = Invoke-Infiltration -PassThru Export-GuerrillaJUnit -Findings $r.Findings -OutputPath ./results.xml if ((Export-GuerrillaJUnit -Findings $r.Findings).Failures) { exit 1 } # gate the pipeline #> [CmdletBinding()] [OutputType('PSGuerrilla.JUnitExport')] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [PSCustomObject[]]$Findings, [string]$OutputPath = (Join-Path (Get-Location) 'PSGuerrilla-results.xml'), [string]$SuiteName = 'PSGuerrilla', [switch]$WarningsAsFailures ) $esc = { param([string]$s) if ($null -eq $s) { return '' } $s.Replace('&', '&').Replace('<', '<').Replace('>', '>').Replace('"', '"').Replace("'", ''') } $all = @($Findings) $totalTests = $all.Count $totalFail = 0; $totalSkip = 0 $sb = [System.Text.StringBuilder]::new() [void]$sb.AppendLine('<?xml version="1.0" encoding="UTF-8"?>') # placeholder for root attributes — counts are patched in after we tally $suiteXml = [System.Text.StringBuilder]::new() $groups = $all | Group-Object -Property { "$($_.Category)" } | Sort-Object Name foreach ($g in $groups) { $cases = @($g.Group) $sFail = 0; $sSkip = 0 $caseXml = [System.Text.StringBuilder]::new() foreach ($f in $cases) { $cls = & $esc "$($g.Name)" $name = & $esc ("$($f.CheckId): $($f.CheckName)") $sev = & $esc "$($f.Severity)" $status = "$($f.Status)" $detail = & $esc ("$($f.CurrentValue)" + $(if ($f.RemediationSteps) { " | Remediation: $($f.RemediationSteps)" } else { '' })) $isFailure = ($status -eq 'FAIL') -or ($WarningsAsFailures -and $status -eq 'WARN') $isSkipped = $status -in @('SKIP', 'ERROR') [void]$caseXml.Append(" <testcase classname=`"$cls`" name=`"$name`" time=`"0`">") if ($isFailure) { $totalFail++; $sFail++ [void]$caseXml.Append("<failure message=`"$detail`" type=`"$sev`">$detail</failure>") } elseif ($isSkipped) { $totalSkip++; $sSkip++ [void]$caseXml.Append("<skipped message=`"Not Assessed: $detail`"/>") } elseif ($status -eq 'WARN') { [void]$caseXml.Append("<system-out>WARN: $detail</system-out>") } [void]$caseXml.AppendLine('</testcase>') } [void]$suiteXml.AppendLine(" <testsuite name=`"$(& $esc $g.Name)`" tests=`"$($cases.Count)`" failures=`"$sFail`" skipped=`"$sSkip`" time=`"0`">") [void]$suiteXml.Append($caseXml.ToString()) [void]$suiteXml.AppendLine(' </testsuite>') } [void]$sb.AppendLine("<testsuites name=`"$(& $esc $SuiteName)`" tests=`"$totalTests`" failures=`"$totalFail`" skipped=`"$totalSkip`" time=`"0`">") [void]$sb.Append($suiteXml.ToString()) [void]$sb.AppendLine('</testsuites>') $dir = Split-Path -Parent $OutputPath if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Force -Path $dir | Out-Null } Set-Content -Path $OutputPath -Value $sb.ToString() -Encoding UTF8 [PSCustomObject]@{ PSTypeName = 'PSGuerrilla.JUnitExport' Path = $OutputPath Tests = $totalTests Failures = $totalFail Skipped = $totalSkip Passed = ($totalTests - $totalFail - $totalSkip) } } |