Update psscriptanalyzer-sarif-reports.yml #30
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: PowerShell Corporate Lint (PSScriptAnalyzer + SARIF + Reports) [Report-Only] | |
| on: | |
| push: | |
| branches: [main, develop] | |
| paths: | |
| - "**/*.ps1" | |
| - "**/*.psm1" | |
| - "**/*.psd1" | |
| - ".psscriptanalyzer.psd1" | |
| - ".github/workflows/psscriptanalyzer-sarif-reports.yml" | |
| pull_request: | |
| branches: [main, develop] | |
| paths: | |
| - "**/*.ps1" | |
| - "**/*.psm1" | |
| - "**/*.psd1" | |
| - ".psscriptanalyzer.psd1" | |
| - ".github/workflows/psscriptanalyzer-sarif-reports.yml" | |
| workflow_dispatch: | |
| concurrency: | |
| group: powershell-lint-${{ github.ref }} | |
| cancel-in-progress: true | |
| permissions: | |
| contents: read | |
| security-events: write | |
| jobs: | |
| psa-analyze: | |
| name: PSScriptAnalyzer Report-Only (SARIF + Artifacts) | |
| runs-on: windows-latest | |
| timeout-minutes: 35 | |
| env: | |
| PSA_VERSION: "1.24.0" | |
| SETTINGS_FILE: ".psscriptanalyzer.psd1" | |
| OUT_DIR: "lint-reports" | |
| OUT_JSON: "psa-results.json" | |
| OUT_CSV: "psa-results.csv" | |
| OUT_MD: "psa-results.md" | |
| OUT_SARIF: "psa-results.sarif" | |
| OUT_FAILTXT: "psa-invocation-failed.txt" | |
| OUT_SUMMARY: "psa-summary.txt" | |
| ANALYZE_ROOTS: >- | |
| BlueTeam-Tools | |
| Core-ScriptLibrary | |
| ITSM-Templates-SVR | |
| ITSM-Templates-WKS | |
| ProSuite-Hub | |
| SysAdmin-Tools | |
| # Subtree excluded due to long paths / GPO export content | |
| EXCLUDE_SUBTREE: "SysAdmin-Tools/GroupPolicyObjects-Templates" | |
| # Directory prune (general) | |
| PRUNE_DIR_REGEX: '(?i)[\\/](\.git|node_modules|dist|build|artifacts|\.next)[\\/]' | |
| # SARIF category (keeps runs distinct in Code Scanning) | |
| SARIF_CATEGORY: "powershell/psscriptanalyzer" | |
| # Corporate-defensible UI wrapper naming conventions (only these can bypass ShouldProcess noise) | |
| # - Show-* (UI display) | |
| # - New-*Form / New-*Dialog / New-*Window | |
| # - Initialize-*UI / Initialize-*Form | |
| # - Update-*UI / Refresh-*UI | |
| UI_WRAPPER_FN_REGEX: '^(Show-|New-.*(Form|Dialog|Window)$|Initialize-.*(UI|Form)$|Update-.*UI$|Refresh-.*UI$|Set-.*(UI|Form)$)' | |
| steps: | |
| - name: Checkout (sparse) | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| sparse-checkout-cone-mode: false | |
| sparse-checkout: | | |
| BlueTeam-Tools | |
| Core-ScriptLibrary | |
| ITSM-Templates-SVR | |
| ITSM-Templates-WKS | |
| ProSuite-Hub | |
| SysAdmin-Tools | |
| .psscriptanalyzer.psd1 | |
| !SysAdmin-Tools/GroupPolicyObjects-Templates | |
| - name: Git long paths | |
| shell: pwsh | |
| run: | | |
| git config --global core.longpaths true | |
| - name: Ensure output folder exists | |
| shell: pwsh | |
| run: | | |
| $outDir = Join-Path $env:GITHUB_WORKSPACE $env:OUT_DIR | |
| New-Item -ItemType Directory -Force -Path $outDir | Out-Null | |
| - name: Initialize SARIF baseline (always create file) | |
| if: always() | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Continue' | |
| $outDir = Join-Path $env:GITHUB_WORKSPACE $env:OUT_DIR | |
| $sarifOut = Join-Path $outDir $env:OUT_SARIF | |
| New-Item -ItemType Directory -Force -Path $outDir | Out-Null | |
| $baseline = @{ | |
| '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' | |
| version = '2.1.0' | |
| runs = @(@{ tool = @{ driver = @{ name = 'PSScriptAnalyzer'; version = '0' } }; results = @() }) | |
| } | |
| $baseline | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $sarifOut | |
| Write-Host "Baseline SARIF created: $sarifOut" | |
| - name: Ensure module cache directories exist | |
| shell: pwsh | |
| run: | | |
| New-Item -ItemType Directory -Force -Path "$HOME\Documents\PowerShell\Modules" | Out-Null | |
| New-Item -ItemType Directory -Force -Path "$HOME\Documents\WindowsPowerShell\Modules" | Out-Null | |
| - name: Cache PowerShell modules | |
| uses: actions/cache@v4 | |
| with: | |
| path: | | |
| ~\Documents\PowerShell\Modules | |
| ~\Documents\WindowsPowerShell\Modules | |
| key: psmodules-${{ runner.os }}-psa-${{ env.PSA_VERSION }} | |
| - name: Install PSScriptAnalyzer | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Stop' | |
| $v = $env:PSA_VERSION | |
| $have = Get-Module -ListAvailable PSScriptAnalyzer | | |
| Sort-Object Version -Descending | | |
| Select-Object -First 1 | |
| if (-not $have -or $have.Version.ToString() -ne $v) { | |
| Install-Module PSScriptAnalyzer -RequiredVersion $v -Force -Scope CurrentUser -AllowClobber | |
| } | |
| Import-Module PSScriptAnalyzer -RequiredVersion $v -Force | |
| Write-Host "PSScriptAnalyzer version loaded: $((Get-Module PSScriptAnalyzer).Version)" | |
| - name: Run PSScriptAnalyzer + Build Reports (REPORT-ONLY) | |
| if: always() | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Continue' | |
| $outDir = Join-Path $env:GITHUB_WORKSPACE $env:OUT_DIR | |
| New-Item -ItemType Directory -Force -Path $outDir | Out-Null | |
| $jsonOut = Join-Path $outDir $env:OUT_JSON | |
| $csvOut = Join-Path $outDir $env:OUT_CSV | |
| $mdOut = Join-Path $outDir $env:OUT_MD | |
| $sarifOut = Join-Path $outDir $env:OUT_SARIF | |
| $failOut = Join-Path $outDir $env:OUT_FAILTXT | |
| $sumOut = Join-Path $outDir $env:OUT_SUMMARY | |
| $root = $env:GITHUB_WORKSPACE | |
| $prune = [regex]::new($env:PRUNE_DIR_REGEX) | |
| $excludeSub = ($env:EXCLUDE_SUBTREE -replace '/', [IO.Path]::DirectorySeparatorChar) | |
| $excludeSubEsc = [regex]::Escape($excludeSub) | |
| $roots = @() | |
| foreach ($token in ($env:ANALYZE_ROOTS -split '\s+')) { | |
| if ([string]::IsNullOrWhiteSpace($token)) { continue } | |
| $p = Join-Path $root $token | |
| if (Test-Path $p) { $roots += $p } | |
| } | |
| $files = @() | |
| if ($roots.Count -gt 0) { | |
| $files = foreach ($r in $roots) { | |
| Get-ChildItem -Path $r -Recurse -File -Include *.ps1,*.psm1,*.psd1 -ErrorAction SilentlyContinue | | |
| Where-Object { | |
| ($_.FullName -notmatch $prune) -and | |
| ($_.FullName -notmatch $excludeSubEsc) | |
| } | |
| } | |
| } | |
| $files = @($files | Sort-Object FullName -Unique) | |
| $count = @($files).Count | |
| $settingsPath = Join-Path $env:GITHUB_WORKSPACE $env:SETTINGS_FILE | |
| $useSettings = (Test-Path $settingsPath) | |
| $settingsArgs = @{} | |
| if ($useSettings) { $settingsArgs['Settings'] = $settingsPath } | |
| $results = @() | |
| try { | |
| if ($count -gt 0) { | |
| $paths = @($files | ForEach-Object { [string]$_.FullName }) | |
| $all = New-Object System.Collections.Generic.List[object] | |
| $hadAnyInvocationError = $false | |
| foreach ($p in $paths) { | |
| if ([string]::IsNullOrWhiteSpace($p)) { continue } | |
| try { | |
| $r = Invoke-ScriptAnalyzer -Path $p -Recurse:$false @settingsArgs | |
| if ($r) { foreach ($item in @($r)) { $all.Add($item) } } | |
| } | |
| catch { | |
| $hadAnyInvocationError = $true | |
| $_ | Out-String | Add-Content -Encoding UTF8 $failOut | |
| } | |
| } | |
| $results = $all.ToArray() | |
| if ($hadAnyInvocationError -and -not (Test-Path $failOut)) { | |
| "One or more file-level PSScriptAnalyzer invocations failed." | Set-Content -Encoding UTF8 $failOut | |
| } | |
| } else { | |
| "No PowerShell files found to analyze." | Set-Content -Encoding UTF8 $sumOut | |
| } | |
| } | |
| catch { | |
| $_ | Out-String | Set-Content -Encoding UTF8 $failOut | |
| $results = @() | |
| } | |
| if (-not $results) { $results = @() } | |
| # Corporate-defensible suppression: | |
| # Only suppress ShouldProcess findings when the finding is inside a UI wrapper function by naming convention. | |
| $uiWrapperRx = [regex]::new($env:UI_WRAPPER_FN_REGEX, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) | |
| function Get-ContainingFunctionName { | |
| param( | |
| [Parameter(Mandatory=$true)][string]$Path, | |
| [Parameter(Mandatory=$true)][int]$Line | |
| ) | |
| try { | |
| if (-not (Test-Path $Path)) { return $null } | |
| $lines = Get-Content -LiteralPath $Path -ErrorAction Stop | |
| if ($Line -lt 1 -or $Line -gt $lines.Count) { return $null } | |
| for ($i = $Line; $i -ge 1; $i--) { | |
| $t = $lines[$i-1] | |
| if ($t -match '^\s*function\s+([A-Za-z_][\w\-]*)\s*(\(|\{|\s)') { | |
| return $Matches[1] | |
| } | |
| } | |
| return $null | |
| } | |
| catch { | |
| return $null | |
| } | |
| } | |
| $filtered = New-Object System.Collections.Generic.List[object] | |
| foreach ($r in @($results)) { | |
| if ([string]$r.RuleName -ne 'PSUseShouldProcessForStateChangingFunctions') { | |
| $filtered.Add($r) | |
| continue | |
| } | |
| $scriptPath = [string]$r.ScriptName | |
| $lineNum = 0 | |
| try { $lineNum = [int]$r.Line } catch { $lineNum = 0 } | |
| $fn = $null | |
| if (-not [string]::IsNullOrWhiteSpace($scriptPath) -and $lineNum -gt 0) { | |
| $fn = Get-ContainingFunctionName -Path $scriptPath -Line $lineNum | |
| } | |
| if ($fn -and $uiWrapperRx.IsMatch($fn)) { | |
| # Suppressed: UI wrapper function by approved naming convention | |
| continue | |
| } | |
| $filtered.Add($r) | |
| } | |
| $results = $filtered.ToArray() | |
| # Always write reports (even if empty) | |
| $results | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $jsonOut | |
| $results | | |
| Select-Object RuleName, Severity, Message, ScriptName, Line, Column | | |
| Export-Csv -Path $csvOut -NoTypeInformation -Encoding UTF8 | |
| $bySev = $results | Group-Object Severity | Sort-Object Name | |
| $byRule = $results | Group-Object RuleName | Sort-Object Count -Descending | |
| $md = New-Object System.Collections.Generic.List[string] | |
| $md.Add("# PSScriptAnalyzer Report (Report-Only)") | |
| $md.Add("") | |
| $md.Add("**Files scanned:** $count") | |
| $md.Add("**Settings file used:** $useSettings ($($env:SETTINGS_FILE))") | |
| $md.Add("**Total findings:** $(@($results).Count)") | |
| $md.Add("") | |
| $md.Add("## Findings by Severity") | |
| if ($bySev.Count -eq 0) { $md.Add("- None") } | |
| else { foreach ($g in $bySev) { $md.Add("- **$($g.Name)**: $($g.Count)") } } | |
| $md.Add("") | |
| $md.Add("## Top Rules") | |
| $top = $byRule | Select-Object -First 20 | |
| if ($top.Count -eq 0) { $md.Add("- None") } | |
| else { foreach ($g in $top) { $md.Add("- **$($g.Name)**: $($g.Count)") } } | |
| $md.Add("") | |
| $md.Add("> Note: This workflow is report-only. Findings do not fail the job. Artifacts are always uploaded.") | |
| $md | Set-Content -Encoding UTF8 $mdOut | |
| $summary = New-Object System.Collections.Generic.List[string] | |
| $summary.Add("PSScriptAnalyzer (Report-Only)") | |
| $summary.Add("Files scanned: $count") | |
| $summary.Add("Settings file used: $useSettings ($($env:SETTINGS_FILE))") | |
| $summary.Add("Total findings: $(@($results).Count)") | |
| foreach ($g in $bySev) { $summary.Add(" $($g.Name): $($g.Count)") } | |
| $summary | Set-Content -Encoding UTF8 $sumOut | |
| function Get-SarifLevel([string]$sev) { | |
| switch -Regex ($sev) { | |
| 'Error' { 'error' } | |
| 'Warning' { 'warning' } | |
| default { 'note' } | |
| } | |
| } | |
| # Update SARIF file (baseline exists already) | |
| try { | |
| $sarif = @{ | |
| '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' | |
| version = '2.1.0' | |
| runs = @( | |
| @{ | |
| tool = @{ | |
| driver = @{ | |
| name = 'PSScriptAnalyzer' | |
| informationUri = 'https://github.com/PowerShell/PSScriptAnalyzer' | |
| version = (Get-Module PSScriptAnalyzer).Version.ToString() | |
| } | |
| } | |
| results = @() | |
| } | |
| ) | |
| } | |
| foreach ($r in $results) { | |
| $scriptRel = [string]$r.ScriptName | |
| if (-not [string]::IsNullOrWhiteSpace($scriptRel)) { | |
| $scriptRel = $scriptRel.Replace("$env:GITHUB_WORKSPACE\", '').Replace("$env:GITHUB_WORKSPACE/", '') | |
| } | |
| $sarif.runs[0].results += @{ | |
| ruleId = [string]$r.RuleName | |
| level = (Get-SarifLevel ([string]$r.Severity)) | |
| message = @{ text = [string]$r.Message } | |
| locations = @( | |
| @{ | |
| physicalLocation = @{ | |
| artifactLocation = @{ uri = $scriptRel } | |
| region = @{ | |
| startLine = [int]$r.Line | |
| startColumn = [int]$r.Column | |
| } | |
| } | |
| } | |
| ) | |
| } | |
| } | |
| $sarif | ConvertTo-Json -Depth 12 | Set-Content -Encoding UTF8 $sarifOut | |
| } | |
| catch { | |
| $_ | Out-String | Add-Content -Encoding UTF8 $failOut | |
| } | |
| exit 0 | |
| - name: Pre-upload diagnostics (ensure SARIF exists) | |
| if: always() | |
| shell: pwsh | |
| run: | | |
| $ErrorActionPreference = 'Continue' | |
| $outDir = Join-Path $env:GITHUB_WORKSPACE $env:OUT_DIR | |
| $sarifOut = Join-Path $outDir $env:OUT_SARIF | |
| Write-Host "GITHUB_WORKSPACE=$env:GITHUB_WORKSPACE" | |
| Write-Host "OUT_DIR=$outDir" | |
| if (Test-Path $outDir) { | |
| Get-ChildItem -Recurse -Force $outDir | Format-Table FullName, Length | |
| } else { | |
| New-Item -ItemType Directory -Force -Path $outDir | Out-Null | |
| } | |
| if (-not (Test-Path $sarifOut)) { | |
| Write-Host "SARIF missing — creating baseline now." | |
| $baseline = @{ | |
| '$schema' = 'https://json.schemastore.org/sarif-2.1.0.json' | |
| version = '2.1.0' | |
| runs = @(@{ tool = @{ driver = @{ name = 'PSScriptAnalyzer'; version = '0' } }; results = @() }) | |
| } | |
| $baseline | ConvertTo-Json -Depth 8 | Set-Content -Encoding UTF8 $sarifOut | |
| } | |
| - name: Upload lint artifacts (never fail if empty) | |
| if: always() | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: powershell-lint-reports | |
| path: ${{ env.OUT_DIR }}/** | |
| if-no-files-found: warn | |
| retention-days: 30 | |
| - name: Upload SARIF to GitHub Code Scanning (report-only) | |
| if: always() && hashFiles(format('{0}/{1}', env.OUT_DIR, env.OUT_SARIF)) != '' | |
| uses: github/codeql-action/upload-sarif@v4 | |
| with: | |
| sarif_file: ${{ env.OUT_DIR }}/${{ env.OUT_SARIF }} | |
| category: ${{ env.SARIF_CATEGORY }} |