PowerShell Scan #541
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 Scan | |
| # Top-level: read-only. The job grants the specific write privilege it needs. | |
| permissions: read-all | |
| on: | |
| workflow_dispatch: | |
| schedule: | |
| - cron: '0 4 * * *' | |
| push: | |
| branches: [master] | |
| paths: | |
| - 'Modules/**' | |
| - 'Tests/**' | |
| - '.github/workflows/powershell-scan.yml' | |
| pull_request: | |
| branches: [master] | |
| paths: | |
| - 'Modules/**' | |
| - 'Tests/**' | |
| jobs: | |
| powershell-scan: | |
| permissions: | |
| issues: write # the github-script step creates / updates "Scan Results:" issues | |
| contents: read # checkout | |
| # windows-2025 (GitHub-hosted) — see ci.yml for the security rationale | |
| # (public repo + self-hosted runner = arbitrary fork code execution) and | |
| # for why we pin the image explicitly ahead of the 2026-06-15 migration. | |
| runs-on: windows-2025 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| - name: Install PSScriptAnalyzer | |
| shell: powershell | |
| run: | | |
| if (-not (Get-Module PSScriptAnalyzer -ListAvailable)) { | |
| Install-Module -Name PSScriptAnalyzer -Force -Scope CurrentUser | |
| } | |
| - name: Run PowerShell Scan | |
| shell: powershell | |
| id: scan | |
| run: | | |
| $timestampUtc = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") | |
| $suffix = (Get-Date).ToUniversalTime().ToString("yyyyMMdd-HHmmss'Z'") | |
| echo "STAMP_SUFFIX=$suffix" >> $env:GITHUB_ENV | |
| $scriptPaths = @( | |
| Join-Path $env:GITHUB_WORKSPACE "Modules" | |
| Join-Path $env:GITHUB_WORKSPACE "Tests" | |
| ) | |
| $files = Get-ChildItem -Path $scriptPaths -Recurse -Include *.ps1,*.psm1 -ErrorAction SilentlyContinue | |
| $issues = @() | |
| function Get-RiskLevel($type, $file) { | |
| if ($type -eq "Secret") { return "High" } | |
| if ($file -match "AD|Network|Disk|Registry|Privileged|Scheduled") { return "High" } | |
| if ($type -eq "Lint") { return "Medium" } | |
| return "Low" | |
| } | |
| foreach ($file in $files) { | |
| # Find PSScriptAnalyzerSettings.psd1 in parent directories | |
| $settingsFile = $null | |
| $searchDir = $file.DirectoryName | |
| while ($searchDir -and $searchDir.StartsWith($env:GITHUB_WORKSPACE)) { | |
| $candidate = Join-Path $searchDir "PSScriptAnalyzerSettings.psd1" | |
| if (Test-Path $candidate) { $settingsFile = $candidate; break } | |
| $searchDir = Split-Path $searchDir -Parent | |
| } | |
| $analyzerParams = @{ Path = $file.FullName; Recurse = $true } | |
| if ($settingsFile) { $analyzerParams["Settings"] = $settingsFile } | |
| $results = Invoke-ScriptAnalyzer @analyzerParams | |
| foreach ($res in $results) { | |
| $issues += [PSCustomObject]@{ | |
| File = $file.FullName | |
| Line = $res.Line | |
| Type = "Lint" | |
| Message = "$($res.RuleName): $($res.Message)" | |
| Risk = (Get-RiskLevel "Lint" $file.FullName) | |
| TimestampUtc = $timestampUtc | |
| } | |
| } | |
| } | |
| # Save reports only if issues exist | |
| $jsonPath = Join-Path $env:GITHUB_WORKSPACE ("scan_results_{0}.json" -f $suffix) | |
| $csvPath = Join-Path $env:GITHUB_WORKSPACE ("scan_results_{0}.csv" -f $suffix) | |
| if ($issues.Count -gt 0) { | |
| $issues | ConvertTo-Json -Depth 10 | Set-Content $jsonPath -Encoding utf8 | |
| $issues | Select-Object File,Line,Type,Message,Risk,TimestampUtc | | |
| Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 | |
| } | |
| # Normalize grouped data and force array output | |
| $grouped = $issues | Group-Object File | |
| $normalizedGroups = @() | |
| foreach ($g in $grouped) { | |
| $normalizedGroups += [PSCustomObject]@{ | |
| Name = $g.Name | |
| Group = $g.Group | |
| } | |
| } | |
| # ConvertTo-Json in Windows PowerShell 5.1 unwraps a single-element | |
| # array into a bare object, which makes the JS consumer in the next | |
| # step throw `TypeError: grouped is not iterable`. Force array shape | |
| # when there's exactly one group so for-of works either way. | |
| $jsonGrouped = if ($normalizedGroups.Count -gt 0) { | |
| $json = @($normalizedGroups) | ConvertTo-Json -Depth 10 | |
| if ($normalizedGroups.Count -eq 1 -and -not $json.StartsWith('[')) { | |
| "[$json]" | |
| } else { | |
| $json | |
| } | |
| } else { | |
| "[]" | |
| } | |
| $encoded = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($jsonGrouped)) | |
| echo "GROUPED_ISSUES_BASE64=$encoded" >> $env:GITHUB_ENV | |
| echo "JSON_PATH=$jsonPath" >> $env:GITHUB_ENV | |
| echo "CSV_PATH=$csvPath" >> $env:GITHUB_ENV | |
| echo "ISSUE_COUNT=$($issues.Count)" >> $env:GITHUB_ENV | |
| - name: Create/Update Issues Per Script | |
| uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 | |
| id: update-issues | |
| with: | |
| github-token: ${{ secrets.GITHUB_TOKEN }} | |
| script: | | |
| const groupedJson = Buffer.from(process.env.GROUPED_ISSUES_BASE64, 'base64').toString('utf8'); | |
| const grouped = JSON.parse(groupedJson); | |
| const issueCount = parseInt(process.env.ISSUE_COUNT || "0"); | |
| const { owner, repo } = context.repo; | |
| const { data: allIssues } = await github.rest.issues.listForRepo({ | |
| owner, repo, state: "all", per_page: 100 | |
| }); | |
| let summaryData = []; | |
| let highRiskFound = false; | |
| if (issueCount === 0) { | |
| console.log("No issues found. Closing any open scan issues."); | |
| for (const i of allIssues.filter(x => x.title.startsWith("Scan Results:") && x.state === "open")) { | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: i.number, | |
| body: "All scripts are clean. Closing this issue." | |
| }); | |
| await github.rest.issues.update({ | |
| owner, repo, issue_number: i.number, state: "closed" | |
| }); | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: i.number, labels: ["Resolved"] | |
| }); | |
| } | |
| } else { | |
| for (const group of grouped) { | |
| const filePath = group.Name; | |
| const fileName = filePath.split(/[\\/]/).pop(); | |
| const issues = group.Group; | |
| let fileIssue = allIssues.find(i => i.title === `Scan Results: ${fileName}`); | |
| if (!issues || issues.length === 0) { | |
| if (fileIssue && fileIssue.state === "open") { | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: fileIssue.number, | |
| body: `Script is clean as of latest scan. Closing issue.` | |
| }); | |
| await github.rest.issues.update({ | |
| owner, repo, issue_number: fileIssue.number, state: "closed" | |
| }); | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: fileIssue.number, labels: ["Resolved"] | |
| }); | |
| } | |
| summaryData.push({ fileName, risk: "Clean", issueUrl: fileIssue ? fileIssue.html_url : "N/A" }); | |
| continue; | |
| } | |
| let summary = `**Scan Results for ${fileName}**\n\n| Line | Type | Message | Risk |\n| ---- | ---- | ------- | ---- |\n`; | |
| for (const issue of issues) { | |
| summary += `| ${issue.Line} | ${issue.Type} | ${issue.Message} | ${issue.Risk} |\n`; | |
| } | |
| if (!fileIssue) { | |
| const { data: newIssue } = await github.rest.issues.create({ | |
| owner, repo, | |
| title: `Scan Results: ${fileName}`, | |
| body: `This issue tracks scan results for ${fileName}.` | |
| }); | |
| fileIssue = newIssue; | |
| } else if (fileIssue.state === "closed") { | |
| await github.rest.issues.update({ | |
| owner, repo, issue_number: fileIssue.number, state: "open" | |
| }); | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: fileIssue.number, | |
| body: `Script has new issues. Reopening this issue.` | |
| }); | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: fileIssue.number, labels: ["Reopened"] | |
| }); | |
| } | |
| await github.rest.issues.createComment({ | |
| owner, repo, issue_number: fileIssue.number, body: summary | |
| }); | |
| const maxRisk = issues.some(i => i.Risk === "High") ? "High" : | |
| issues.some(i => i.Risk === "Medium") ? "Medium" : "Low"; | |
| if (maxRisk === "High") highRiskFound = true; | |
| const labels = maxRisk === "High" ? ["High Risk"] : | |
| maxRisk === "Medium" ? ["Medium Risk"] : ["Low Risk"]; | |
| await github.rest.issues.addLabels({ | |
| owner, repo, issue_number: fileIssue.number, labels | |
| }); | |
| summaryData.push({ fileName, risk: maxRisk, issueUrl: fileIssue.html_url }); | |
| } | |
| } | |
| core.setOutput("summaryData", JSON.stringify(summaryData)); | |
| core.setOutput("highRiskFound", highRiskFound); | |
| - name: Generate Workflow Summary | |
| shell: powershell | |
| run: | | |
| $summaryData = '${{ steps.update-issues.outputs.summaryData }}' | ConvertFrom-Json | |
| $total = @($summaryData).Count | |
| $highCount = @($summaryData | Where-Object { $_.risk -eq "High" }).Count | |
| $mediumCount = @($summaryData | Where-Object { $_.risk -eq "Medium" }).Count | |
| $lowCount = @($summaryData | Where-Object { $_.risk -eq "Low" }).Count | |
| $cleanCount = @($summaryData | Where-Object { $_.risk -eq "Clean" }).Count | |
| $report = "## PowerShell Scan Summary`n" | |
| $report += "**Total Scripts Scanned:** $total`n" | |
| $report += "**High Risk:** $highCount | **Medium Risk:** $mediumCount | **Low Risk:** $lowCount | **Clean:** $cleanCount`n`n" | |
| $report += "| Script | Risk | Issue Link |`n| ------ | ---- | ---------- |`n" | |
| foreach ($item in $summaryData) { | |
| $report += "| $($item.fileName) | $($item.risk) | $($item.issueUrl) |`n" | |
| } | |
| $report | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Encoding utf8 | |
| - name: Upload Scan Reports | |
| if: ${{ env.ISSUE_COUNT != '0' }} | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: powershell-scan-results-${{ env.STAMP_SUFFIX }} | |
| path: | | |
| ${{ env.JSON_PATH }} | |
| ${{ env.CSV_PATH }} | |
| retention-days: 30 | |
| - name: Fail on High Risk | |
| if: ${{ steps.update-issues.outputs.highRiskFound == 'true' }} | |
| shell: powershell | |
| run: | | |
| Write-Host "High Risk issues detected. Failing the workflow." | |
| exit 1 |