diff --git a/.github/workflows/pr.yaml b/.github/workflows/pr.yaml index 2f47882..19221f2 100644 --- a/.github/workflows/pr.yaml +++ b/.github/workflows/pr.yaml @@ -241,7 +241,167 @@ jobs: echo "has-projects=false" >> "$GITHUB_OUTPUT" echo "ℹ️ No .NET project files found - skipping .NET build and test jobs" fi + # ============================================================================ + # INSPECTCODE: JetBrains ReSharper InspectCode parallel to the test stages. + # Runs concurrently with Stage 1 / 2 / 3 (no `needs:` on a test job) so it + # doesn't extend wall-clock — typically 3–5 min vs the slowest stage. + # SARIF uploads to GitHub Code Scanning (Security tab + inline PR + # annotations). `error`-severity findings fail the job; `warning`-severity + # surfaces in Code Scanning but doesn't fail (gated by --severity=WARNING + # filter). Tune the noise floor via .DotSettings at the repo root. + # ============================================================================ + inspectcode: + name: "ReSharper InspectCode" + # Runs on Windows so .NET Framework reference assemblies (System, + # System.Xml.Linq, etc.) resolve natively for any net462 / net472 / net48 + # projects the solution includes. On ubuntu-latest InspectCode fails + # with 60+ MSB3245 assembly-resolution errors on Framework-target + # projects unless we install mono — Windows has them out of the box. + # The build + test stages parallel this on Linux/Windows/macOS, so the + # Windows load here is not additive to the wall-clock the way an extra + # test stage would be. + runs-on: windows-latest + # All step scripts below use bash syntax (process substitution, [[ ]], + # etc.) — pin the default shell so it works uniformly on windows-latest + # (which defaults to pwsh) and not accidentally on any future runner + # swap. + defaults: + run: + shell: bash + needs: detect-projects + if: github.repository != 'Chris-Wolfgang/repo-template' && needs.detect-projects.outputs.has-projects == 'true' + timeout-minutes: 20 + permissions: + contents: read + security-events: write # required for upload-sarif + + steps: + - name: Checkout code + uses: actions/checkout@v7 + with: + ref: refs/pull/${{ github.event.pull_request.number }}/head + persist-credentials: false + + # Same defense-in-depth pattern as detect-projects and the test stages: + # jobs don't share workspaces under pull_request_target, so each build/ + # analyzer job re-fetches protected config from main after checkout. + # Without this, a malicious PR could ship a permissive .editorconfig / + # BannedSymbols.txt / .DotSettings that would silence InspectCode + # findings on the PR's own code. + - name: Fetch trusted configuration files from main branch + # Skip for Dependabot — its package-version bumps to protected files are + # legitimate and should not be overwritten by main's older versions. + if: github.event.pull_request.user.login != 'dependabot[bot]' + run: | + echo "Fetching configuration files from main branch to prevent malicious overrides..." + + git fetch origin main:main-branch + + config_files=( + ".editorconfig" + "Directory.Build.props" + "Directory.Build.targets" + "BannedSymbols.txt" + "*.globalconfig" + "*.ruleset" + ".github/workflows/*.yml" + ".github/workflows/*.yaml" + ) + + for config_file in "${config_files[@]}"; do + if [[ "$config_file" == *"*"* ]]; then + # See Stage 1 for the process-substitution rationale (avoids a + # subshell that would swallow `exit 1` on failed copies). + while read -r file; do + if [ -n "$file" ]; then + echo " ✓ Copying $file from main branch" + mkdir -p "$(dirname "$file")" + if ! git show "main-branch:$file" > "$file"; then + echo "::error::Failed to copy $file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 + fi + fi + done < <(git ls-tree -r --name-only main-branch | { grep -E "${config_file//\*/.*}" || true; }) + else + if git cat-file -e "main-branch:$config_file" 2>/dev/null; then + echo " ✓ Copying $config_file from main branch" + if ! git show "main-branch:$config_file" > "$config_file"; then + echo "::error::Failed to copy $config_file from main-branch — aborting to prevent silent fall-back to PR-supplied protected config." + exit 1 + fi + else + echo " ℹ️ $config_file not found in main branch, skipping" + fi + fi + done + + echo "" + echo "✅ Configuration files secured - using versions from main branch" + + - name: Setup .NET + uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + # Restore + build everything the solution knows about. On + # windows-latest .NET Framework 4.x reference assemblies are bundled, + # so net462 / net472 / net48 projects (e.g. examples/CSharp.DotNet462.Example) + # build natively alongside net5+ / netstandard / netcoreapp projects + # — no filter loop needed. InspectCode then reads the built outputs + # (with `--no-build`) for the whole solution. + - name: Restore and build + run: | + dotnet restore + dotnet build -c Release --no-restore + + - name: Install JetBrains.ReSharper.GlobalTools + run: dotnet tool install -g JetBrains.ReSharper.GlobalTools + - name: Run InspectCode + run: | + # Find a solution to inspect. Prefer .slnx (new format) then .sln. + # InspectCode requires SOMETHING solution-shaped — fail loudly if neither exists. + SLN=$(ls *.slnx 2>/dev/null | head -n1) + if [ -z "$SLN" ]; then + SLN=$(ls *.sln 2>/dev/null | head -n1) + fi + if [ -z "$SLN" ]; then + echo "::error::No .slnx or .sln found at repo root — InspectCode needs one to run." + exit 1 + fi + echo "Inspecting: $SLN" + jb inspectcode "$SLN" \ + --output=inspect.sarif \ + --format=sarif \ + --severity=WARNING \ + --no-build + + - name: Upload SARIF to Code Scanning + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: inspect.sarif + + - name: Gate on error-severity findings + # PowerShell (native on windows-latest) so we don't depend on jq + # being preinstalled. Parses the SARIF via ConvertFrom-Json and + # counts results at level=error. Warnings still upload (visible in + # Security → Code scanning) but don't gate merge — raise to `error` + # later when the noise floor is acceptable. + shell: pwsh + run: | + $sarif = Get-Content inspect.sarif -Raw | ConvertFrom-Json + $count = @( + foreach ($run in $sarif.runs) { + foreach ($result in $run.results) { + if ($result.level -eq 'error') { $result } + } + } + ).Count + if ($count -gt 0) { + Write-Host "::error::$count InspectCode error-severity finding(s) — see Security → Code scanning" + exit 1 + } + Write-Host "✅ No error-severity InspectCode findings." # ============================================================================ # STAGE 1: Linux - .NET Core/5+ Tests with Coverage Gate # ============================================================================