From 57991a67135d8d0bc81130f0ae15b1c148dbce43 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 24 Aug 2026 20:12:15 +0300 Subject: [PATCH 01/22] Refactor release workflow with advisory practices --- .github/scripts/artifact-checksums.ps1 | 90 ++++++ .github/workflows/build-and-publish.yml | 378 +++++++++++++++++++++++- .github/workflows/sbom.yml | 83 +++--- 3 files changed, 499 insertions(+), 52 deletions(-) create mode 100644 .github/scripts/artifact-checksums.ps1 diff --git a/.github/scripts/artifact-checksums.ps1 b/.github/scripts/artifact-checksums.ps1 new file mode 100644 index 0000000..51f6612 --- /dev/null +++ b/.github/scripts/artifact-checksums.ps1 @@ -0,0 +1,90 @@ +<# +.SYNOPSIS + Writes or verifies a SHA-256 manifest for an artifact handed between workflow jobs. + +.DESCRIPTION + The manifest cannot cover itself, so 'Write' emits the manifest's own SHA-256 as the + 'manifest-sha256' step output. Passing that value back through -ExpectedManifestHash lets a + consuming job establish the integrity of the handoff out of band from the artifact itself. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)][ValidateSet('Write', 'Verify')] + [string]$Mode, + + [Parameter(Mandatory)] + [string]$ManifestPath, + + [Parameter(Mandatory)] + [string[]]$Path, + + [string]$ExpectedManifestHash +) + +$ErrorActionPreference = 'Stop' +$root = (Get-Location).Path + +function Get-Entries { + Get-ChildItem -Path $Path -Recurse -File | + Sort-Object FullName | + ForEach-Object { + [pscustomobject]@{ + Hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() + Path = [IO.Path]::GetRelativePath($root, $_.FullName).Replace('\', '/') + } + } +} + +if ($Mode -eq 'Write') { + $lines = @(Get-Entries | ForEach-Object { "$($_.Hash) $($_.Path)" }) + if ($lines.Count -eq 0) { + throw "No files matched '$($Path -join ', ')'. Refusing to write an empty checksum manifest." + } + + Set-Content -LiteralPath $ManifestPath -Value $lines -Encoding ascii + $manifestHash = (Get-FileHash -LiteralPath $ManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() + + Write-Host "Recorded $($lines.Count) files in $ManifestPath (manifest SHA-256 $manifestHash)." + if ($env:GITHUB_OUTPUT) { + "manifest-sha256=$manifestHash" | Add-Content -LiteralPath $env:GITHUB_OUTPUT + } + return +} + +if (-not (Test-Path -LiteralPath $ManifestPath)) { + throw "Checksum manifest not found: $ManifestPath" +} + +if ([string]::IsNullOrWhiteSpace($ExpectedManifestHash)) { + throw "-ExpectedManifestHash is required in Verify mode; the manifest cannot vouch for itself." +} + +$manifestHash = (Get-FileHash -LiteralPath $ManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() +if ($manifestHash -ne $ExpectedManifestHash) { + throw "Checksum manifest was altered in transit. Expected $ExpectedManifestHash but found $manifestHash." +} + +$expected = @{} +foreach ($line in Get-Content -LiteralPath $ManifestPath) { + if ([string]::IsNullOrWhiteSpace($line)) { continue } + $hash, $relative = $line -split ' ', 2 + $expected[$relative] = $hash +} + +$actual = @{} +foreach ($entry in Get-Entries) { $actual[$entry.Path] = $entry.Hash } + +$problems = @() +foreach ($relative in $expected.Keys) { + if (-not $actual.ContainsKey($relative)) { $problems += "missing: $relative" } + elseif ($actual[$relative] -ne $expected[$relative]) { $problems += "modified: $relative" } +} +foreach ($relative in $actual.Keys) { + if (-not $expected.ContainsKey($relative)) { $problems += "unexpected: $relative" } +} + +if ($problems.Count -gt 0) { + throw "Artifact integrity check failed against ${ManifestPath}:`n$($problems -join "`n")" +} + +Write-Host "Verified $($expected.Count) files against $ManifestPath (manifest SHA-256 $manifestHash)." diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 5bca910..7801e60 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -2,20 +2,24 @@ name: Build and Publish on: release: - types: [created] - + types: [published] + +permissions: {} + env: BUILD_CONFIGURATION: Release DOTNET_VERSION: '9.x' VERSION: ${{ github.ref_name }} jobs: - build-sign-publish: + # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. + build: + name: Build runs-on: windows-latest - environment: nuget-org-publish permissions: - id-token: write contents: read + outputs: + manifest-sha256: ${{ steps.checksums.outputs.manifest-sha256 }} steps: - name: Checkout @@ -55,6 +59,56 @@ jobs: shell: pwsh run: Remove-Item "${{ runner.temp }}\IG.StrongName.snk" -Force -ErrorAction SilentlyContinue + # obj/ travels with bin/ so the later pack job can run --no-build --no-restore. + - name: Record build output checksums + id: checksums + shell: pwsh + run: .github/scripts/artifact-checksums.ps1 -Mode Write -ManifestPath build-output.sha256 -Path bin, obj + + - name: Upload build output + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: build-output + path: | + bin/** + obj/** + build-output.sha256 + retention-days: 1 + if-no-files-found: error + + sign-assemblies: + name: Sign assemblies + needs: build + runs-on: windows-latest + permissions: + contents: read + id-token: write + outputs: + manifest-sha256: ${{ steps.checksums.outputs.manifest-sha256 }} + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Download build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-output + path: . + + - name: Verify build output + shell: pwsh + run: > + .github/scripts/artifact-checksums.ps1 -Mode Verify -ManifestPath build-output.sha256 -Path bin, obj + -ExpectedManifestHash ${{ needs.build.outputs.manifest-sha256 }} + - name: Restore .NET local tools run: dotnet tool restore @@ -92,6 +146,55 @@ jobs: } Write-Host "All DLLs signed successfully." + - name: Record signed assembly checksums + id: checksums + shell: pwsh + run: .github/scripts/artifact-checksums.ps1 -Mode Write -ManifestPath signed-assemblies.sha256 -Path bin, obj + + - name: Upload signed assemblies + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signed-assemblies + path: | + bin/** + obj/** + signed-assemblies.sha256 + retention-days: 1 + if-no-files-found: error + + pack: + name: Pack and sign package + needs: sign-assemblies + runs-on: windows-latest + permissions: + contents: read + id-token: write + outputs: + nupkg-sha256: ${{ steps.digest.outputs.nupkg-sha256 }} + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Download signed assemblies + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: signed-assemblies + path: . + + - name: Verify signed assemblies + shell: pwsh + run: > + .github/scripts/artifact-checksums.ps1 -Mode Verify -ManifestPath signed-assemblies.sha256 -Path bin, obj + -ExpectedManifestHash ${{ needs.sign-assemblies.outputs.manifest-sha256 }} + - name: Pack NuGet package run: dotnet pack ./Infragistics.QueryBuilder.Executor.csproj --no-build --no-restore --configuration ${{ env.BUILD_CONFIGURATION }} -p:PackageVersion=${{ env.VERSION }} -o "${{ github.workspace }}/nupkg" @@ -139,6 +242,16 @@ jobs: Remove-Item $validationRoot -Recurse -Force -ErrorAction SilentlyContinue } + - name: Restore .NET local tools + run: dotnet tool restore + + - name: Authenticate to Azure + uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Sign NuGet package shell: pwsh run: > @@ -153,6 +266,211 @@ jobs: - name: Validate NuGet package signature run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" --verbosity quiet + # This digest is the identity every downstream job re-checks before acting on the package. + - name: Record package digest + id: digest + shell: pwsh + run: | + $name = "Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $package = "${{ github.workspace }}\nupkg\$name" + $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() + + "$digest $name" | Set-Content -LiteralPath "$package.sha256" -Encoding ascii + "nupkg-sha256=$digest" | Add-Content -LiteralPath $env:GITHUB_OUTPUT + + @("### Signed package digest", '```', "$digest $name", '```') | + Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + - name: Upload signed package + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: nupkg-signed + path: nupkg/* + retention-days: 30 + if-no-files-found: error + + sbom: + name: Generate SBOM and attest + needs: pack + runs-on: windows-latest + permissions: + contents: read + id-token: write + attestations: write + outputs: + attested-sha256: ${{ steps.verify.outputs.nupkg-sha256 }} + + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Download signed package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nupkg-signed + path: nupkg + + - name: Verify signed package digest + id: verify + shell: pwsh + run: | + $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() + $expected = "${{ needs.pack.outputs.nupkg-sha256 }}" + + if ($digest -ne $expected) { + throw "Package digest changed between jobs. Expected $expected but found $digest." + } + + "nupkg-sha256=$digest" | Add-Content -LiteralPath $env:GITHUB_OUTPUT + Write-Host "Verified package digest $digest." + + # obj/project.assets.json is what component detection reads for the transitive NuGet graph. + - name: Restore project dependencies + run: dotnet restore Infragistics.QueryBuilder.Executor.csproj + + # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore'. + - name: Restore sbom-tool (pinned) + run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json + + # -b is the signed package folder, so the shipped nupkg and its hash land in the SBOM's files section. + # -bc scans the repository for the dependency graph; -li/-pm resolve license and supplier metadata. + - name: Generate SBOM (SPDX 2.2) + working-directory: .config/sbom-tool + run: > + dotnet tool run sbom-tool -- generate + -b ${{ github.workspace }}/nupkg + -bc ${{ github.workspace }} + -m ${{ github.workspace }}/sbom/spdx-2.2 + -pn Infragistics.QueryBuilder.Executor + -pv ${{ env.VERSION }} + -ps "Infragistics Inc." + -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor + -mi SPDX:2.2 + -li true + -lto 60 + -pm true + -V Information + + - name: Generate SBOM (SPDX 3.0) + working-directory: .config/sbom-tool + run: > + dotnet tool run sbom-tool -- generate + -b ${{ github.workspace }}/nupkg + -bc ${{ github.workspace }} + -m ${{ github.workspace }}/sbom/spdx-3.0 + -pn Infragistics.QueryBuilder.Executor + -pv ${{ env.VERSION }} + -ps "Infragistics Inc." + -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor + -mi SPDX:3.0 + -li true + -lto 60 + -pm true + -V Information + + - name: Verify SBOM output + shell: pwsh + run: | + $name = "Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $spdx22 = "${{ github.workspace }}\sbom\spdx-2.2\_manifest\spdx_2.2\manifest.spdx.json" + $spdx30 = "${{ github.workspace }}\sbom\spdx-3.0\_manifest\spdx_3.0\manifest.spdx.json" + + foreach ($document in @($spdx22, $spdx30)) { + if (-not (Test-Path -LiteralPath $document) -or (Get-Item -LiteralPath $document).Length -eq 0) { + throw "SBOM document missing or empty: $document" + } + } + + $spdx = Get-Content -LiteralPath $spdx22 -Raw | ConvertFrom-Json + if (-not ($spdx.files | Where-Object { $_.fileName -like "*$name" })) { + throw "The SPDX 2.2 document does not reference $name." + } + + Write-Host "SBOM covers $($spdx.packages.Count) packages and $($spdx.files.Count) files." + + - name: Attest build provenance + id: attest-provenance + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: nupkg/*.nupkg + + # actions/attest derives the predicate from an SPDX 2.x or CycloneDX document; SPDX 3.0 ships as evidence only. + - name: Attest SBOM + id: attest-sbom + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-path: nupkg/*.nupkg + sbom-path: sbom/spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json + + - name: Collect attestation bundles + shell: pwsh + run: | + $target = "${{ github.workspace }}\sbom\attestations" + New-Item -ItemType Directory -Path $target -Force | Out-Null + Copy-Item "${{ steps.attest-provenance.outputs.bundle-path }}" (Join-Path $target 'provenance.sigstore.json') + Copy-Item "${{ steps.attest-sbom.outputs.bundle-path }}" (Join-Path $target 'sbom.sigstore.json') + + @( + "### Attestations", + "- Provenance: ${{ steps.attest-provenance.outputs.attestation-url }}", + "- SBOM: ${{ steps.attest-sbom.outputs.attestation-url }}" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + - name: Upload SBOM and attestations + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: sbom + path: sbom/** + retention-days: 30 + if-no-files-found: error + + # The only job that can publish. It compiles nothing and never checks out the repository. + publish: + name: Publish to NuGet.org + needs: [pack, sbom] + runs-on: windows-latest + environment: nuget-org-publish + permissions: + contents: read + id-token: write + + steps: + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Download signed package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nupkg-signed + path: nupkg + + - name: Verify the package that was packed, signed, and attested + shell: pwsh + run: | + $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() + + foreach ($expected in @("${{ needs.pack.outputs.nupkg-sha256 }}", "${{ needs.sbom.outputs.attested-sha256 }}")) { + if ($digest -ne $expected) { + throw "Refusing to publish: expected digest $expected but found $digest." + } + } + + Write-Host "Publishing package with digest $digest." + + - name: Validate NuGet package signature + run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" --verbosity quiet + - name: NuGet login (OIDC Trusted Publishing) uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 id: nuget-login @@ -161,3 +479,53 @@ jobs: - name: Publish to NuGet.org run: dotnet nuget push "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" + + - name: Record published digest + shell: pwsh + run: | + @( + "### Published to NuGet.org", + '`${{ needs.pack.outputs.nupkg-sha256 }}`', + "", + "NuGet.org repository-signs every uploaded package, so the digest it serves differs from the one above.", + "Verify attestations against the .nupkg attached to this release." + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + + attach-to-release: + name: Attach release evidence + needs: [pack, sbom, publish] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download signed package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nupkg-signed + path: nupkg + + - name: Download SBOM and attestations + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: sbom + path: sbom + + - name: Attach evidence to the release + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + PACKAGE_ID: Infragistics.QueryBuilder.Executor + run: | + set -euo pipefail + + (cd sbom/spdx-2.2/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-2.2.zip" .) + (cd sbom/spdx-3.0/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-3.0.zip" .) + + gh release upload "$TAG" --clobber -R "${{ github.repository }}" \ + "nupkg/${PACKAGE_ID}.${TAG}.nupkg" \ + "nupkg/${PACKAGE_ID}.${TAG}.nupkg.sha256" \ + "${PACKAGE_ID}.${TAG}.spdx-2.2.zip" \ + "${PACKAGE_ID}.${TAG}.spdx-3.0.zip" \ + "sbom/attestations/provenance.sigstore.json" \ + "sbom/attestations/sbom.sigstore.json" diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 340b835..6a7ec8d 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -1,29 +1,27 @@ name: Generate SBOM -# Runs when the 'generate sbom' label is added to a PR, or when a release is published. -# Uses the sbom-tool CLI (version pinned in .config/sbom-tool/dotnet-tools.json) -# so the NuGet package is never modified and no SBOM is embedded in it. +# Dry run for the SBOM that build-and-publish.yml produces at release time, so flag changes can be +# reviewed on a PR. The package packed here is unsigned, so nothing is attested or published: +# provenance and SBOM attestations must bind to the signed package digest. on: pull_request: types: [labeled] - release: - types: [published] permissions: contents: read concurrency: - group: sbom-${{ github.event_name == 'release' && github.ref_name || github.event.pull_request.number }} + group: sbom-${{ github.event.pull_request.number }} cancel-in-progress: true env: BUILD_CONFIGURATION: Release DOTNET_VERSION: '9.x' - PACKAGE_VERSION: ${{ github.event_name == 'release' && github.ref_name || format('0.0.0-pr.{0}', github.event.pull_request.number) }} + PACKAGE_VERSION: 0.0.0-pr.${{ github.event.pull_request.number }} jobs: sbom: - if: github.event_name == 'release' || github.event.label.name == 'generate sbom' + if: github.event.label.name == 'generate sbom' runs-on: ubuntu-latest steps: @@ -45,65 +43,56 @@ jobs: - name: Restore sbom-tool (pinned) run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json - # -b: the shipped artifact (nupkg) gets listed with its hash in the SBOM's files section + # -b: the packed nupkg gets listed with its hash in the SBOM's files section # -bc: dependency detection scans the repo root (csproj) - # -mi SPDX:3.0: emit the SBOM in SPDX 3.0 format (output goes to _manifest/spdx_3.0) - - name: Generate SBOM + # -li/-pm: resolve license and supplier metadata for the detected packages + - name: Generate SBOM (SPDX 2.2) working-directory: .config/sbom-tool run: > dotnet tool run sbom-tool -- generate -b ${{ github.workspace }}/artifacts -bc ${{ github.workspace }} + -m ${{ github.workspace }}/sbom/spdx-2.2 + -pn Infragistics.QueryBuilder.Executor + -pv ${{ env.PACKAGE_VERSION }} + -ps "Infragistics Inc." + -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor + -mi SPDX:2.2 + -li true + -lto 60 + -pm true + -V Information + + - name: Generate SBOM (SPDX 3.0) + working-directory: .config/sbom-tool + run: > + dotnet tool run sbom-tool -- generate + -b ${{ github.workspace }}/artifacts + -bc ${{ github.workspace }} + -m ${{ github.workspace }}/sbom/spdx-3.0 -pn Infragistics.QueryBuilder.Executor -pv ${{ env.PACKAGE_VERSION }} -ps "Infragistics Inc." -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor -mi SPDX:3.0 + -li true + -lto 60 + -pm true -V Information - name: Verify SBOM run: | set -euo pipefail - test -s artifacts/_manifest/spdx_3.0/manifest.spdx.json - test -s artifacts/_manifest/spdx_3.0/manifest.spdx.json.sha256 + test -s sbom/spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json + test -s sbom/spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json.sha256 + test -s sbom/spdx-3.0/_manifest/spdx_3.0/manifest.spdx.json + test -s sbom/spdx-3.0/_manifest/spdx_3.0/manifest.spdx.json.sha256 echo "SBOM generated successfully." - - name: Upload NuGet package - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: nupkg - path: artifacts/*.nupkg - retention-days: 1 - if-no-files-found: error - - name: Upload SBOM files uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: sbom-spdx_3.0 - path: artifacts/_manifest/spdx_3.0 + name: sbom + path: sbom/** retention-days: 1 if-no-files-found: error - - attach-to-release: - if: github.event_name == 'release' - needs: sbom - runs-on: ubuntu-latest - permissions: - contents: write # required to upload release assets - - steps: - - name: Download SBOM artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: sbom-spdx_3.0 - path: spdx_3.0 - - - name: Attach SBOM to release - env: - GH_TOKEN: ${{ github.token }} - TAG: ${{ github.ref_name }} - run: | - set -euo pipefail - asset="Infragistics.QueryBuilder.Executor.${TAG}.spdx_3.0.zip" - (cd spdx_3.0 && zip -r "../${asset}" .) - gh release upload "$TAG" "$asset" --clobber -R "${{ github.repository }}" From 8ee932fb4d384682d2d7b7a6f7a58a1e2cff48a2 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 24 Aug 2026 20:18:07 +0300 Subject: [PATCH 02/22] Include hidden files for hash verification as well --- .github/workflows/build-and-publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 7801e60..7464168 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -73,6 +73,8 @@ jobs: bin/** obj/** build-output.sha256 + # obj/ contains dot-prefixed generated sources, which are otherwise dropped as hidden files. + include-hidden-files: true retention-days: 1 if-no-files-found: error @@ -159,6 +161,7 @@ jobs: bin/** obj/** signed-assemblies.sha256 + include-hidden-files: true retention-days: 1 if-no-files-found: error From d6600cb86c1d51868a44211177a1e47ea37f592c Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 24 Aug 2026 20:32:09 +0300 Subject: [PATCH 03/22] re-use the gh env for the sign-assemblies job - for now --- .github/workflows/build-and-publish.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 7464168..3de51c9 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -82,6 +82,8 @@ jobs: name: Sign assemblies needs: build runs-on: windows-latest + # The environment is what makes the OIDC subject claim match the Entra federated credential. + environment: nuget-org-publish permissions: contents: read id-token: write @@ -169,6 +171,7 @@ jobs: name: Pack and sign package needs: sign-assemblies runs-on: windows-latest + environment: nuget-org-publish permissions: contents: read id-token: write From d28a2bbb187fa426ce7316d09bd13cc2b1a4d4e6 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 24 Aug 2026 20:44:19 +0300 Subject: [PATCH 04/22] explicitly create the sbom output dirs --- .github/workflows/build-and-publish.yml | 5 +++++ .github/workflows/sbom.yml | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 3de51c9..637e535 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -346,6 +346,11 @@ jobs: - name: Restore sbom-tool (pinned) run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json + # sbom-tool fails unless the -m directory already exists. + - name: Create SBOM output directories + shell: pwsh + run: New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\sbom\spdx-2.2", "${{ github.workspace }}\sbom\spdx-3.0" | Out-Null + # -b is the signed package folder, so the shipped nupkg and its hash land in the SBOM's files section. # -bc scans the repository for the dependency graph; -li/-pm resolve license and supplier metadata. - name: Generate SBOM (SPDX 2.2) diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index 6a7ec8d..c810d71 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -43,6 +43,10 @@ jobs: - name: Restore sbom-tool (pinned) run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json + # sbom-tool fails unless the -m directory already exists. + - name: Create SBOM output directories + run: mkdir -p sbom/spdx-2.2 sbom/spdx-3.0 + # -b: the packed nupkg gets listed with its hash in the SBOM's files section # -bc: dependency detection scans the repo root (csproj) # -li/-pm: resolve license and supplier metadata for the detected packages From ea3ea17d5f9d4465534ba53aaf320035a61e5155 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 24 Aug 2026 20:52:02 +0300 Subject: [PATCH 05/22] drop the extra explicit shallow clone - it's already implicit --- .github/workflows/build-and-publish.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 637e535..f27a3c7 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -25,7 +25,6 @@ jobs: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: - fetch-depth: 0 persist-credentials: false - name: Setup .NET From 63886c090a1809d96f29af3670dc6641dba34601 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Mon, 24 Aug 2026 21:22:14 +0300 Subject: [PATCH 06/22] tweaks and security hardening --- .github/workflows/build-and-publish.yml | 54 +++++++++++++++++-------- .github/workflows/sbom.yml | 6 ++- 2 files changed, 42 insertions(+), 18 deletions(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index f27a3c7..97a22c7 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -6,6 +6,9 @@ on: permissions: {} +concurrency: + group: release-${{ github.ref_name }} + env: BUILD_CONFIGURATION: Release DOTNET_VERSION: '9.x' @@ -16,6 +19,7 @@ jobs: build: name: Build runs-on: windows-latest + timeout-minutes: 20 permissions: contents: read outputs: @@ -49,7 +53,7 @@ jobs: run: | dotnet build Infragistics.QueryBuilder.Executor.csproj ` -c ${{ env.BUILD_CONFIGURATION }} ` - /p:Version=${{ env.VERSION }} ` + /p:Version=$env:VERSION ` /p:SignAssembly=true ` /p:AssemblyOriginatorKeyFile="${{ runner.temp }}\IG.StrongName.snk" @@ -81,6 +85,7 @@ jobs: name: Sign assemblies needs: build runs-on: windows-latest + timeout-minutes: 20 # The environment is what makes the OIDC subject claim match the Entra federated credential. environment: nuget-org-publish permissions: @@ -108,9 +113,11 @@ jobs: - name: Verify build output shell: pwsh + env: + EXPECTED_MANIFEST_HASH: ${{ needs.build.outputs.manifest-sha256 }} run: > .github/scripts/artifact-checksums.ps1 -Mode Verify -ManifestPath build-output.sha256 -Path bin, obj - -ExpectedManifestHash ${{ needs.build.outputs.manifest-sha256 }} + -ExpectedManifestHash $env:EXPECTED_MANIFEST_HASH - name: Restore .NET local tools run: dotnet tool restore @@ -170,6 +177,7 @@ jobs: name: Pack and sign package needs: sign-assemblies runs-on: windows-latest + timeout-minutes: 20 environment: nuget-org-publish permissions: contents: read @@ -196,17 +204,19 @@ jobs: - name: Verify signed assemblies shell: pwsh + env: + EXPECTED_MANIFEST_HASH: ${{ needs.sign-assemblies.outputs.manifest-sha256 }} run: > .github/scripts/artifact-checksums.ps1 -Mode Verify -ManifestPath signed-assemblies.sha256 -Path bin, obj - -ExpectedManifestHash ${{ needs.sign-assemblies.outputs.manifest-sha256 }} + -ExpectedManifestHash $env:EXPECTED_MANIFEST_HASH - name: Pack NuGet package - run: dotnet pack ./Infragistics.QueryBuilder.Executor.csproj --no-build --no-restore --configuration ${{ env.BUILD_CONFIGURATION }} -p:PackageVersion=${{ env.VERSION }} -o "${{ github.workspace }}/nupkg" + run: dotnet pack ./Infragistics.QueryBuilder.Executor.csproj --no-build --no-restore --configuration ${{ env.BUILD_CONFIGURATION }} -p:PackageVersion=$env:VERSION -o "${{ github.workspace }}/nupkg" - name: Validate packaged assembly strong names shell: pwsh run: | - $packagePath = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $packagePath = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" $validationRoot = "${{ runner.temp }}\strong-name-validation" $archivePath = "$validationRoot\package.zip" $extractPath = "$validationRoot\package" @@ -269,14 +279,14 @@ jobs: --verbosity Warning - name: Validate NuGet package signature - run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" --verbosity quiet + run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --verbosity quiet # This digest is the identity every downstream job re-checks before acting on the package. - name: Record package digest id: digest shell: pwsh run: | - $name = "Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $name = "Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" $package = "${{ github.workspace }}\nupkg\$name" $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() @@ -298,6 +308,7 @@ jobs: name: Generate SBOM and attest needs: pack runs-on: windows-latest + timeout-minutes: 20 permissions: contents: read id-token: write @@ -325,10 +336,12 @@ jobs: - name: Verify signed package digest id: verify shell: pwsh + env: + EXPECTED_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} run: | - $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() - $expected = "${{ needs.pack.outputs.nupkg-sha256 }}" + $expected = $env:EXPECTED_NUPKG_SHA256 if ($digest -ne $expected) { throw "Package digest changed between jobs. Expected $expected but found $digest." @@ -360,7 +373,7 @@ jobs: -bc ${{ github.workspace }} -m ${{ github.workspace }}/sbom/spdx-2.2 -pn Infragistics.QueryBuilder.Executor - -pv ${{ env.VERSION }} + -pv $env:VERSION -ps "Infragistics Inc." -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor -mi SPDX:2.2 @@ -377,7 +390,7 @@ jobs: -bc ${{ github.workspace }} -m ${{ github.workspace }}/sbom/spdx-3.0 -pn Infragistics.QueryBuilder.Executor - -pv ${{ env.VERSION }} + -pv $env:VERSION -ps "Infragistics Inc." -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor -mi SPDX:3.0 @@ -389,7 +402,7 @@ jobs: - name: Verify SBOM output shell: pwsh run: | - $name = "Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $name = "Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" $spdx22 = "${{ github.workspace }}\sbom\spdx-2.2\_manifest\spdx_2.2\manifest.spdx.json" $spdx30 = "${{ github.workspace }}\sbom\spdx-3.0\_manifest\spdx_3.0\manifest.spdx.json" @@ -447,6 +460,7 @@ jobs: name: Publish to NuGet.org needs: [pack, sbom] runs-on: windows-latest + timeout-minutes: 15 environment: nuget-org-publish permissions: contents: read @@ -466,11 +480,14 @@ jobs: - name: Verify the package that was packed, signed, and attested shell: pwsh + env: + PACK_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} + ATTESTED_NUPKG_SHA256: ${{ needs.sbom.outputs.attested-sha256 }} run: | - $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" + $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() - foreach ($expected in @("${{ needs.pack.outputs.nupkg-sha256 }}", "${{ needs.sbom.outputs.attested-sha256 }}")) { + foreach ($expected in @($env:PACK_NUPKG_SHA256, $env:ATTESTED_NUPKG_SHA256)) { if ($digest -ne $expected) { throw "Refusing to publish: expected digest $expected but found $digest." } @@ -479,7 +496,7 @@ jobs: Write-Host "Publishing package with digest $digest." - name: Validate NuGet package signature - run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" --verbosity quiet + run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --verbosity quiet - name: NuGet login (OIDC Trusted Publishing) uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 @@ -488,14 +505,16 @@ jobs: user: ${{ secrets.INFRAGISTICS_NUGET_ORG_USER }} - name: Publish to NuGet.org - run: dotnet nuget push "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${{ env.VERSION }}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" + run: dotnet nuget push "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" - name: Record published digest shell: pwsh + env: + PACK_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} run: | @( "### Published to NuGet.org", - '`${{ needs.pack.outputs.nupkg-sha256 }}`', + ('`' + $env:PACK_NUPKG_SHA256 + '`'), "", "NuGet.org repository-signs every uploaded package, so the digest it serves differs from the one above.", "Verify attestations against the .nupkg attached to this release." @@ -505,6 +524,7 @@ jobs: name: Attach release evidence needs: [pack, sbom, publish] runs-on: ubuntu-latest + timeout-minutes: 10 permissions: contents: write diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index c810d71..aa8e7a0 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -23,9 +23,13 @@ jobs: sbom: if: github.event.label.name == 'generate sbom' runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 From 6b9aaa6ba5cda1ef33a47029f386f3de7c880b2e Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 25 Aug 2026 17:25:19 +0300 Subject: [PATCH 07/22] Try adding a pinned public snk for better verification and auditing --- .github/scripts/verify-strong-name.ps1 | 110 ++++++++++++++++++++++++ .github/workflows/build-and-publish.yml | 48 +++++------ eng/IG.publickey.hex | 15 ++++ 3 files changed, 148 insertions(+), 25 deletions(-) create mode 100644 .github/scripts/verify-strong-name.ps1 create mode 100644 eng/IG.publickey.hex diff --git a/.github/scripts/verify-strong-name.ps1 b/.github/scripts/verify-strong-name.ps1 new file mode 100644 index 0000000..480d839 --- /dev/null +++ b/.github/scripts/verify-strong-name.ps1 @@ -0,0 +1,110 @@ +<# +.SYNOPSIS + Verifies that assemblies are strong-name signed with the approved Infragistics key. + +.DESCRIPTION + 'sn.exe -vf' proves only that an assembly's strong name is internally consistent, so any valid + private key passes it. This script additionally compares each assembly's public key against a + value pinned in the repository and established out of band from the signing key, so a + substituted key fails the build instead of establishing an unintended binary identity. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string[]]$Path, + + [Parameter(Mandatory)] + [string]$ExpectedPublicKeyPath +) + +$ErrorActionPreference = 'Stop' + +# Failures are aggregated per assembly, so sn.exe exit codes must not throw on their own. +$PSNativeCommandUseErrorActionPreference = $false + +function ConvertTo-HexString([byte[]]$Bytes) { + return (-join ($Bytes | ForEach-Object { $_.ToString('x2') })) +} + +if (-not (Test-Path -LiteralPath $ExpectedPublicKeyPath)) { + throw "Pinned public key file not found: $ExpectedPublicKeyPath" +} + +$hexLines = @( + Get-Content -LiteralPath $ExpectedPublicKeyPath | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and -not $_.StartsWith('#') } +) + +# A blank or malformed pin must fail loudly; otherwise the whole check silently becomes a no-op. +if ($hexLines.Count -ne 1) { + throw "$ExpectedPublicKeyPath must contain exactly one non-comment line, but contains $($hexLines.Count)." +} + +$expectedPublicKeyHex = $hexLines[0].ToLowerInvariant() +if ($expectedPublicKeyHex -notmatch '^[0-9a-f]{320,}$' -or $expectedPublicKeyHex.Length % 2 -ne 0) { + throw "$ExpectedPublicKeyPath does not hold a public key blob (expected an even number of at least 320 hex characters)." +} + +$expectedPublicKey = [byte[]]::new($expectedPublicKeyHex.Length / 2) +for ($i = 0; $i -lt $expectedPublicKey.Length; $i++) { + $expectedPublicKey[$i] = [Convert]::ToByte($expectedPublicKeyHex.Substring($i * 2, 2), 16) +} + +# SHA-1 is not a security choice here; it is the algorithm that defines a strong-name token. +$digest = [System.Security.Cryptography.SHA1]::Create().ComputeHash($expectedPublicKey) +$tokenBytes = $digest[-8..-1] +[array]::Reverse($tokenBytes) +$expectedToken = ConvertTo-HexString $tokenBytes + +$windowsSdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Microsoft SDKs\Windows' +$strongNameTool = Get-ChildItem -Path $windowsSdkRoot -Filter 'sn.exe' -Recurse -ErrorAction SilentlyContinue | + Sort-Object FullName -Descending | + Select-Object -First 1 + +if ($null -eq $strongNameTool) { + throw "Could not find sn.exe under $windowsSdkRoot." +} + +$assemblies = @(Get-ChildItem -Path $Path -Filter '*.dll' -Recurse -File) +if ($assemblies.Count -eq 0) { + throw "No assemblies were found under '$($Path -join ', ')'. Refusing to report success." +} + +$problems = @() +foreach ($assembly in $assemblies) { + $output = & $strongNameTool.FullName -vf $assembly.FullName + if ($LASTEXITCODE -ne 0) { + $problems += "$($assembly.FullName): strong-name verification failed. $(($output | Where-Object { $_ }) -join ' ')" + continue + } + + $assemblyName = [System.Reflection.AssemblyName]::GetAssemblyName($assembly.FullName) + + $token = $assemblyName.GetPublicKeyToken() + if ($null -eq $token -or $token.Length -eq 0) { + $problems += "$($assembly.FullName): not strong named." + continue + } + + $actualToken = ConvertTo-HexString $token + if ($actualToken -ne $expectedToken) { + $problems += "$($assembly.FullName): public key token is $actualToken, expected $expectedToken." + continue + } + + # Best effort: the token is a truncated hash, so compare the whole key when it is available. + $publicKey = $assemblyName.GetPublicKey() + if ($null -ne $publicKey -and $publicKey.Length -gt 0) { + $actualPublicKey = ConvertTo-HexString $publicKey + if ($actualPublicKey -ne $expectedPublicKeyHex) { + $problems += "$($assembly.FullName): public key does not match $ExpectedPublicKeyPath despite a matching token." + } + } +} + +if ($problems.Count -gt 0) { + throw "Strong-name validation failed:`n$($problems -join "`n")" +} + +Write-Host "Verified $($assemblies.Count) assemblies against public key token $expectedToken." diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 97a22c7..7c05c37 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -13,6 +13,8 @@ env: BUILD_CONFIGURATION: Release DOTNET_VERSION: '9.x' VERSION: ${{ github.ref_name }} + # Public, deliberately pinned identity. The strong-name counterpart lives in eng/IG.publickey.hex. + EXPECTED_CERT_SUBJECT_CN: 'Infragistics, Inc.' jobs: # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. @@ -144,17 +146,34 @@ jobs: run: | $dlls = Get-ChildItem -Path "${{ github.workspace }}\bin\${{ env.BUILD_CONFIGURATION }}" -Filter "*.dll" -Recurse $failed = @() + $thumbprints = @{} foreach ($dll in $dlls) { $sig = Get-AuthenticodeSignature $dll.FullName if ($sig.Status -ne 'Valid') { - $failed += $dll.FullName + $failed += "$($dll.FullName): signature status $($sig.Status)." + continue } + + # A valid signature only proves some trusted certificate was used, not the approved one. + $cn = $sig.SignerCertificate.GetNameInfo('SimpleName', $false) + if ($cn -ne $env:EXPECTED_CERT_SUBJECT_CN) { + $failed += "$($dll.FullName): signed by '$cn', expected '$($env:EXPECTED_CERT_SUBJECT_CN)'." + continue + } + + $thumbprints[$sig.SignerCertificate.Thumbprint] = $true } if ($failed.Count -gt 0) { - Write-Error "Unsigned DLLs found:`n$($failed -join "`n")" + Write-Error "Authenticode validation failed:`n$($failed -join "`n")" exit 1 } - Write-Host "All DLLs signed successfully." + Write-Host "All $($dlls.Count) DLLs signed by '$($env:EXPECTED_CERT_SUBJECT_CN)'." + + @( + "### Authenticode signer", + "- Subject CN: $($env:EXPECTED_CERT_SUBJECT_CN)", + "- Thumbprint: $($thumbprints.Keys -join ', ')" + ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY - name: Record signed assembly checksums id: checksums @@ -230,28 +249,7 @@ jobs: Copy-Item $packagePath $archivePath Expand-Archive -Path $archivePath -DestinationPath $extractPath -Force - $assemblies = @(Get-ChildItem -Path $extractPath -Filter "*.dll" -Recurse) - if ($assemblies.Count -eq 0) { - throw "No assemblies were found in the NuGet package." - } - - $windowsSdkRoot = Join-Path ${env:ProgramFiles(x86)} "Microsoft SDKs\Windows" - $strongNameTool = Get-ChildItem -Path $windowsSdkRoot -Filter "sn.exe" -Recurse | - Sort-Object FullName -Descending | - Select-Object -First 1 - - if ($null -eq $strongNameTool) { - throw "Could not find sn.exe in the Windows SDK." - } - - foreach ($assembly in $assemblies) { - & $strongNameTool.FullName -vf $assembly.FullName - if ($LASTEXITCODE -ne 0) { - throw "Strong-name verification failed: $($assembly.FullName)" - } - } - - Write-Host "Verified $($assemblies.Count) strong-name signed package assemblies." + .github/scripts/verify-strong-name.ps1 -Path $extractPath -ExpectedPublicKeyPath eng/IG.publickey.hex } finally { Remove-Item $validationRoot -Recurse -Force -ErrorAction SilentlyContinue diff --git a/eng/IG.publickey.hex b/eng/IG.publickey.hex new file mode 100644 index 0000000..939c6a0 --- /dev/null +++ b/eng/IG.publickey.hex @@ -0,0 +1,15 @@ +# Infragistics strong-name public key, as the raw public key blob in hex. +# +# This is PUBLIC data: it is embedded in every assembly we ship, published in nuget.org metadata, +# and copied into every consumer's binding redirects. It is pinned here so that signing with a +# different key fails the release instead of silently establishing a new PublicKeyToken. +# +# Public key token: 7dd5c3163f2cd0cb +# +# Established out of band from the IG_STRONG_NAME_KEY secret. Three independent sources agree: +# 1. dev-tools ASP.NET\Utils\IGStrongName.key (public-key blob, byte-identical to this value) +# 2. dev-tools XPlatform\Main\Source\CommonFiles.WinForms\AssemblyVersion.cs (PublicKey_WF) +# 3. the shipped, signed Infragistics.QueryBuilder.Executor 1.0.2-prerelease.33 assemblies +# +# Re-derive with: sn -Tp +002400000480000094000000060200000024000052534131000400000100010001afa6285b0af5cdd03aa2b6fdaf33fc4759cf9cd9bcf8b778ae60b9fcf71fc8126b78dbf930519614013b7999297907dd9c00bcc487a14f4c6733fe9adb96c053f005d7148f1666fcb882a0f9ba4307c85694b3322889dab357ad5cefd72ccc45e1b6973bdd2f15b2a300077b8d9de30739200887c5407c8a68c90345cbc4f1 From 9317613ca91d6efae167a57613b86113c3c12f4b Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 25 Aug 2026 17:50:59 +0300 Subject: [PATCH 08/22] tweaks --- eng/IG.publickey.hex | 5 ----- 1 file changed, 5 deletions(-) diff --git a/eng/IG.publickey.hex b/eng/IG.publickey.hex index 939c6a0..fbc393f 100644 --- a/eng/IG.publickey.hex +++ b/eng/IG.publickey.hex @@ -6,10 +6,5 @@ # # Public key token: 7dd5c3163f2cd0cb # -# Established out of band from the IG_STRONG_NAME_KEY secret. Three independent sources agree: -# 1. dev-tools ASP.NET\Utils\IGStrongName.key (public-key blob, byte-identical to this value) -# 2. dev-tools XPlatform\Main\Source\CommonFiles.WinForms\AssemblyVersion.cs (PublicKey_WF) -# 3. the shipped, signed Infragistics.QueryBuilder.Executor 1.0.2-prerelease.33 assemblies -# # Re-derive with: sn -Tp 002400000480000094000000060200000024000052534131000400000100010001afa6285b0af5cdd03aa2b6fdaf33fc4759cf9cd9bcf8b778ae60b9fcf71fc8126b78dbf930519614013b7999297907dd9c00bcc487a14f4c6733fe9adb96c053f005d7148f1666fcb882a0f9ba4307c85694b3322889dab357ad5cefd72ccc45e1b6973bdd2f15b2a300077b8d9de30739200887c5407c8a68c90345cbc4f1 From 3d7cff397115cc6369ad4ad495a7fe48c58367a5 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 26 Aug 2026 20:01:43 +0300 Subject: [PATCH 09/22] Further refactoring and simplification --- .github/scripts/artifact-checksums.ps1 | 90 ------------- .github/scripts/verify-strong-name.ps1 | 9 +- .github/workflows/build-and-publish.yml | 162 +++++++++--------------- 3 files changed, 63 insertions(+), 198 deletions(-) delete mode 100644 .github/scripts/artifact-checksums.ps1 diff --git a/.github/scripts/artifact-checksums.ps1 b/.github/scripts/artifact-checksums.ps1 deleted file mode 100644 index 51f6612..0000000 --- a/.github/scripts/artifact-checksums.ps1 +++ /dev/null @@ -1,90 +0,0 @@ -<# -.SYNOPSIS - Writes or verifies a SHA-256 manifest for an artifact handed between workflow jobs. - -.DESCRIPTION - The manifest cannot cover itself, so 'Write' emits the manifest's own SHA-256 as the - 'manifest-sha256' step output. Passing that value back through -ExpectedManifestHash lets a - consuming job establish the integrity of the handoff out of band from the artifact itself. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory)][ValidateSet('Write', 'Verify')] - [string]$Mode, - - [Parameter(Mandatory)] - [string]$ManifestPath, - - [Parameter(Mandatory)] - [string[]]$Path, - - [string]$ExpectedManifestHash -) - -$ErrorActionPreference = 'Stop' -$root = (Get-Location).Path - -function Get-Entries { - Get-ChildItem -Path $Path -Recurse -File | - Sort-Object FullName | - ForEach-Object { - [pscustomobject]@{ - Hash = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant() - Path = [IO.Path]::GetRelativePath($root, $_.FullName).Replace('\', '/') - } - } -} - -if ($Mode -eq 'Write') { - $lines = @(Get-Entries | ForEach-Object { "$($_.Hash) $($_.Path)" }) - if ($lines.Count -eq 0) { - throw "No files matched '$($Path -join ', ')'. Refusing to write an empty checksum manifest." - } - - Set-Content -LiteralPath $ManifestPath -Value $lines -Encoding ascii - $manifestHash = (Get-FileHash -LiteralPath $ManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() - - Write-Host "Recorded $($lines.Count) files in $ManifestPath (manifest SHA-256 $manifestHash)." - if ($env:GITHUB_OUTPUT) { - "manifest-sha256=$manifestHash" | Add-Content -LiteralPath $env:GITHUB_OUTPUT - } - return -} - -if (-not (Test-Path -LiteralPath $ManifestPath)) { - throw "Checksum manifest not found: $ManifestPath" -} - -if ([string]::IsNullOrWhiteSpace($ExpectedManifestHash)) { - throw "-ExpectedManifestHash is required in Verify mode; the manifest cannot vouch for itself." -} - -$manifestHash = (Get-FileHash -LiteralPath $ManifestPath -Algorithm SHA256).Hash.ToLowerInvariant() -if ($manifestHash -ne $ExpectedManifestHash) { - throw "Checksum manifest was altered in transit. Expected $ExpectedManifestHash but found $manifestHash." -} - -$expected = @{} -foreach ($line in Get-Content -LiteralPath $ManifestPath) { - if ([string]::IsNullOrWhiteSpace($line)) { continue } - $hash, $relative = $line -split ' ', 2 - $expected[$relative] = $hash -} - -$actual = @{} -foreach ($entry in Get-Entries) { $actual[$entry.Path] = $entry.Hash } - -$problems = @() -foreach ($relative in $expected.Keys) { - if (-not $actual.ContainsKey($relative)) { $problems += "missing: $relative" } - elseif ($actual[$relative] -ne $expected[$relative]) { $problems += "modified: $relative" } -} -foreach ($relative in $actual.Keys) { - if (-not $expected.ContainsKey($relative)) { $problems += "unexpected: $relative" } -} - -if ($problems.Count -gt 0) { - throw "Artifact integrity check failed against ${ManifestPath}:`n$($problems -join "`n")" -} - -Write-Host "Verified $($expected.Count) files against $ManifestPath (manifest SHA-256 $manifestHash)." diff --git a/.github/scripts/verify-strong-name.ps1 b/.github/scripts/verify-strong-name.ps1 index 480d839..62d9913 100644 --- a/.github/scripts/verify-strong-name.ps1 +++ b/.github/scripts/verify-strong-name.ps1 @@ -5,8 +5,7 @@ .DESCRIPTION 'sn.exe -vf' proves only that an assembly's strong name is internally consistent, so any valid private key passes it. This script additionally compares each assembly's public key against a - value pinned in the repository and established out of band from the signing key, so a - substituted key fails the build instead of establishing an unintended binary identity. + value pinned in the repository and established out of band from the signing key. #> [CmdletBinding()] param( @@ -18,7 +17,6 @@ param( ) $ErrorActionPreference = 'Stop' - # Failures are aggregated per assembly, so sn.exe exit codes must not throw on their own. $PSNativeCommandUseErrorActionPreference = $false @@ -47,8 +45,8 @@ if ($expectedPublicKeyHex -notmatch '^[0-9a-f]{320,}$' -or $expectedPublicKeyHex } $expectedPublicKey = [byte[]]::new($expectedPublicKeyHex.Length / 2) -for ($i = 0; $i -lt $expectedPublicKey.Length; $i++) { - $expectedPublicKey[$i] = [Convert]::ToByte($expectedPublicKeyHex.Substring($i * 2, 2), 16) +for ($index = 0; $index -lt $expectedPublicKey.Length; $index++) { + $expectedPublicKey[$index] = [Convert]::ToByte($expectedPublicKeyHex.Substring($index * 2, 2), 16) } # SHA-1 is not a security choice here; it is the algorithm that defines a strong-name token. @@ -80,7 +78,6 @@ foreach ($assembly in $assemblies) { } $assemblyName = [System.Reflection.AssemblyName]::GetAssemblyName($assembly.FullName) - $token = $assemblyName.GetPublicKeyToken() if ($null -eq $token -or $token.Length -eq 0) { $problems += "$($assembly.FullName): not strong named." diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 7c05c37..f049cbb 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -15,6 +15,8 @@ env: VERSION: ${{ github.ref_name }} # Public, deliberately pinned identity. The strong-name counterpart lives in eng/IG.publickey.hex. EXPECTED_CERT_SUBJECT_CN: 'Infragistics, Inc.' + # Bound sbom-tool's external license lookup. + SBOM_LICENSE_TIMEOUT_SECONDS: '180' jobs: # Holds the strong-name key, but no OIDC token, no Key Vault access and no publishing rights. @@ -24,8 +26,6 @@ jobs: timeout-minutes: 20 permissions: contents: read - outputs: - manifest-sha256: ${{ steps.checksums.outputs.manifest-sha256 }} steps: - name: Checkout @@ -38,6 +38,9 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore .NET dependencies + run: dotnet restore Infragistics.QueryBuilder.Executor.csproj + - name: Restore strong-name key shell: pwsh env: @@ -54,22 +57,17 @@ jobs: shell: pwsh run: | dotnet build Infragistics.QueryBuilder.Executor.csproj ` - -c ${{ env.BUILD_CONFIGURATION }} ` - /p:Version=$env:VERSION ` - /p:SignAssembly=true ` - /p:AssemblyOriginatorKeyFile="${{ runner.temp }}\IG.StrongName.snk" + --configuration ${{ env.BUILD_CONFIGURATION }} ` + --no-restore ` + -p:Version=$env:VERSION ` + -p:SignAssembly=true ` + -p:AssemblyOriginatorKeyFile="${{ runner.temp }}\IG.StrongName.snk" - name: Delete strong-name key if: always() shell: pwsh run: Remove-Item "${{ runner.temp }}\IG.StrongName.snk" -Force -ErrorAction SilentlyContinue - # obj/ travels with bin/ so the later pack job can run --no-build --no-restore. - - name: Record build output checksums - id: checksums - shell: pwsh - run: .github/scripts/artifact-checksums.ps1 -Mode Write -ManifestPath build-output.sha256 -Path bin, obj - - name: Upload build output uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -77,8 +75,6 @@ jobs: path: | bin/** obj/** - build-output.sha256 - # obj/ contains dot-prefixed generated sources, which are otherwise dropped as hidden files. include-hidden-files: true retention-days: 1 if-no-files-found: error @@ -93,8 +89,6 @@ jobs: permissions: contents: read id-token: write - outputs: - manifest-sha256: ${{ steps.checksums.outputs.manifest-sha256 }} steps: - name: Checkout @@ -112,14 +106,7 @@ jobs: with: name: build-output path: . - - - name: Verify build output - shell: pwsh - env: - EXPECTED_MANIFEST_HASH: ${{ needs.build.outputs.manifest-sha256 }} - run: > - .github/scripts/artifact-checksums.ps1 -Mode Verify -ManifestPath build-output.sha256 -Path bin, obj - -ExpectedManifestHash $env:EXPECTED_MANIFEST_HASH + digest-mismatch: error - name: Restore .NET local tools run: dotnet tool restore @@ -144,7 +131,11 @@ jobs: - name: Validate DLL signatures shell: pwsh run: | - $dlls = Get-ChildItem -Path "${{ github.workspace }}\bin\${{ env.BUILD_CONFIGURATION }}" -Filter "*.dll" -Recurse + $dlls = @(Get-ChildItem -Path "${{ github.workspace }}\bin\${{ env.BUILD_CONFIGURATION }}" -Filter '*.dll' -Recurse -File) + if ($dlls.Count -eq 0) { + throw "No DLLs found to validate." + } + $failed = @() $thumbprints = @{} foreach ($dll in $dlls) { @@ -164,9 +155,9 @@ jobs: $thumbprints[$sig.SignerCertificate.Thumbprint] = $true } if ($failed.Count -gt 0) { - Write-Error "Authenticode validation failed:`n$($failed -join "`n")" - exit 1 + throw "Authenticode validation failed:`n$($failed -join "`n")" } + Write-Host "All $($dlls.Count) DLLs signed by '$($env:EXPECTED_CERT_SUBJECT_CN)'." @( @@ -175,11 +166,6 @@ jobs: "- Thumbprint: $($thumbprints.Keys -join ', ')" ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY - - name: Record signed assembly checksums - id: checksums - shell: pwsh - run: .github/scripts/artifact-checksums.ps1 -Mode Write -ManifestPath signed-assemblies.sha256 -Path bin, obj - - name: Upload signed assemblies uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -187,7 +173,6 @@ jobs: path: | bin/** obj/** - signed-assemblies.sha256 include-hidden-files: true retention-days: 1 if-no-files-found: error @@ -220,17 +205,16 @@ jobs: with: name: signed-assemblies path: . - - - name: Verify signed assemblies - shell: pwsh - env: - EXPECTED_MANIFEST_HASH: ${{ needs.sign-assemblies.outputs.manifest-sha256 }} - run: > - .github/scripts/artifact-checksums.ps1 -Mode Verify -ManifestPath signed-assemblies.sha256 -Path bin, obj - -ExpectedManifestHash $env:EXPECTED_MANIFEST_HASH + digest-mismatch: error - name: Pack NuGet package - run: dotnet pack ./Infragistics.QueryBuilder.Executor.csproj --no-build --no-restore --configuration ${{ env.BUILD_CONFIGURATION }} -p:PackageVersion=$env:VERSION -o "${{ github.workspace }}/nupkg" + run: > + dotnet pack Infragistics.QueryBuilder.Executor.csproj + --configuration ${{ env.BUILD_CONFIGURATION }} + --no-build + --no-restore + -p:PackageVersion=$env:VERSION + -o "${{ github.workspace }}/nupkg" - name: Validate packaged assembly strong names shell: pwsh @@ -339,63 +323,50 @@ jobs: run: | $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() - $expected = $env:EXPECTED_NUPKG_SHA256 - - if ($digest -ne $expected) { - throw "Package digest changed between jobs. Expected $expected but found $digest." + if ($digest -ne $env:EXPECTED_NUPKG_SHA256) { + throw "Package digest changed between jobs. Expected $($env:EXPECTED_NUPKG_SHA256) but found $digest." } "nupkg-sha256=$digest" | Add-Content -LiteralPath $env:GITHUB_OUTPUT Write-Host "Verified package digest $digest." - # obj/project.assets.json is what component detection reads for the transitive NuGet graph. - - name: Restore project dependencies + - name: Restore .NET dependencies run: dotnet restore Infragistics.QueryBuilder.Executor.csproj - # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore'. + # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore' used by the sign steps. - name: Restore sbom-tool (pinned) run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json - # sbom-tool fails unless the -m directory already exists. - - name: Create SBOM output directories - shell: pwsh - run: New-Item -ItemType Directory -Force -Path "${{ github.workspace }}\sbom\spdx-2.2", "${{ github.workspace }}\sbom\spdx-3.0" | Out-Null - # -b is the signed package folder, so the shipped nupkg and its hash land in the SBOM's files section. # -bc scans the repository for the dependency graph; -li/-pm resolve license and supplier metadata. - - name: Generate SBOM (SPDX 2.2) + - name: Generate SBOMs working-directory: .config/sbom-tool - run: > - dotnet tool run sbom-tool -- generate - -b ${{ github.workspace }}/nupkg - -bc ${{ github.workspace }} - -m ${{ github.workspace }}/sbom/spdx-2.2 - -pn Infragistics.QueryBuilder.Executor - -pv $env:VERSION - -ps "Infragistics Inc." - -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor - -mi SPDX:2.2 - -li true - -lto 60 - -pm true - -V Information - - - name: Generate SBOM (SPDX 3.0) - working-directory: .config/sbom-tool - run: > - dotnet tool run sbom-tool -- generate - -b ${{ github.workspace }}/nupkg - -bc ${{ github.workspace }} - -m ${{ github.workspace }}/sbom/spdx-3.0 - -pn Infragistics.QueryBuilder.Executor - -pv $env:VERSION - -ps "Infragistics Inc." - -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor - -mi SPDX:3.0 - -li true - -lto 60 - -pm true - -V Information + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $PSNativeCommandUseErrorActionPreference = $true + + $formats = @( + @{ Version = 'SPDX:2.2'; Output = 'spdx-2.2' }, + @{ Version = 'SPDX:3.0'; Output = 'spdx-3.0' } + ) + + foreach ($format in $formats) { + Write-Host "Generating $($format.Version) SBOM..." + dotnet tool run sbom-tool -- generate ` + -b "$env:GITHUB_WORKSPACE\nupkg" ` + -bc $env:GITHUB_WORKSPACE ` + -m "$env:GITHUB_WORKSPACE\sbom\$($format.Output)" ` + -pn Infragistics.QueryBuilder.Executor ` + -pv $env:VERSION ` + -ps "Infragistics Inc." ` + -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor ` + -mi $format.Version ` + -li true ` + -lto $env:SBOM_LICENSE_TIMEOUT_SECONDS ` + -pm true ` + -V Information + } - name: Verify SBOM output shell: pwsh @@ -404,9 +375,9 @@ jobs: $spdx22 = "${{ github.workspace }}\sbom\spdx-2.2\_manifest\spdx_2.2\manifest.spdx.json" $spdx30 = "${{ github.workspace }}\sbom\spdx-3.0\_manifest\spdx_3.0\manifest.spdx.json" - foreach ($document in @($spdx22, $spdx30)) { - if (-not (Test-Path -LiteralPath $document) -or (Get-Item -LiteralPath $document).Length -eq 0) { - throw "SBOM document missing or empty: $document" + foreach ($manifestPath in @($spdx22, $spdx30)) { + if (-not (Test-Path -LiteralPath $manifestPath) -or (Get-Item -LiteralPath $manifestPath).Length -eq 0) { + throw "SBOM manifest missing or empty: $manifestPath" } } @@ -505,19 +476,6 @@ jobs: - name: Publish to NuGet.org run: dotnet nuget push "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" - - name: Record published digest - shell: pwsh - env: - PACK_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} - run: | - @( - "### Published to NuGet.org", - ('`' + $env:PACK_NUPKG_SHA256 + '`'), - "", - "NuGet.org repository-signs every uploaded package, so the digest it serves differs from the one above.", - "Verify attestations against the .nupkg attached to this release." - ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY - attach-to-release: name: Attach release evidence needs: [pack, sbom, publish] From b7c5817f25110c7f46d438550737bfb7c25677c4 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 26 Aug 2026 20:55:00 +0300 Subject: [PATCH 10/22] Fix the SBOM manifest dir --- .github/workflows/build-and-publish.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index f049cbb..3da2d9b 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -352,11 +352,15 @@ jobs: ) foreach ($format in $formats) { + $manifestDirectory = Join-Path $env:GITHUB_WORKSPACE "sbom\$($format.Output)" + # sbom-tool requires the manifest directory to exist before generation. + New-Item -ItemType Directory -Path $manifestDirectory -Force | Out-Null + Write-Host "Generating $($format.Version) SBOM..." dotnet tool run sbom-tool -- generate ` -b "$env:GITHUB_WORKSPACE\nupkg" ` -bc $env:GITHUB_WORKSPACE ` - -m "$env:GITHUB_WORKSPACE\sbom\$($format.Output)" ` + -m $manifestDirectory ` -pn Infragistics.QueryBuilder.Executor ` -pv $env:VERSION ` -ps "Infragistics Inc." ` From 2ae96ce030d51dd7533163624ff9e563fc21ba83 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Thu, 27 Aug 2026 20:41:59 +0300 Subject: [PATCH 11/22] Improve validations in the release workflow --- .github/scripts/verify-strong-name.ps1 | 42 +++++++++++++++++++++---- .github/workflows/build-and-publish.yml | 34 +++++++++++++------- eng/IG.authenticode-certificates.sha256 | 3 ++ 3 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 eng/IG.authenticode-certificates.sha256 diff --git a/.github/scripts/verify-strong-name.ps1 b/.github/scripts/verify-strong-name.ps1 index 62d9913..0be5287 100644 --- a/.github/scripts/verify-strong-name.ps1 +++ b/.github/scripts/verify-strong-name.ps1 @@ -13,7 +13,9 @@ param( [string[]]$Path, [Parameter(Mandatory)] - [string]$ExpectedPublicKeyPath + [string]$ExpectedPublicKeyPath, + + [string]$SnPath ) $ErrorActionPreference = 'Stop' @@ -55,15 +57,43 @@ $tokenBytes = $digest[-8..-1] [array]::Reverse($tokenBytes) $expectedToken = ConvertTo-HexString $tokenBytes -$windowsSdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Microsoft SDKs\Windows' -$strongNameTool = Get-ChildItem -Path $windowsSdkRoot -Filter 'sn.exe' -Recurse -ErrorAction SilentlyContinue | - Sort-Object FullName -Descending | - Select-Object -First 1 +if ($SnPath) { + if (-not (Test-Path -LiteralPath $SnPath -PathType Leaf)) { + throw "The specified sn.exe path does not exist: $SnPath" + } + + $strongNameTool = Get-Item -LiteralPath $SnPath +} +else { + $strongNameCommand = Get-Command sn.exe -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + + if ($null -ne $strongNameCommand) { + $strongNameTool = Get-Item -LiteralPath $strongNameCommand.Path + } + else { + $windowsSdkRoot = Join-Path ${env:ProgramFiles(x86)} 'Microsoft SDKs\Windows' + $strongNameTool = Get-ChildItem -Path $windowsSdkRoot -Filter 'sn.exe' -Recurse -ErrorAction SilentlyContinue | + Sort-Object -Property @{ + Expression = { + $match = [regex]::Match($_.FullName, '\\v(?\d+(?:\.\d+)*)A?\\', 'IgnoreCase') + if ($match.Success) { [version]$match.Groups['version'].Value } else { [version]'0.0' } + } + Descending = $true + }, @{ + Expression = { $_.FullName } + Descending = $true + } | + Select-Object -First 1 + } +} if ($null -eq $strongNameTool) { - throw "Could not find sn.exe under $windowsSdkRoot." + throw 'Could not find sn.exe on PATH or under the Windows SDK directory. Pass -SnPath explicitly.' } +Write-Verbose "Using sn.exe from '$($strongNameTool.FullName)'." + $assemblies = @(Get-ChildItem -Path $Path -Filter '*.dll' -Recurse -File) if ($assemblies.Count -eq 0) { throw "No assemblies were found under '$($Path -join ', ')'. Refusing to report success." diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 3da2d9b..41713d7 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -14,7 +14,7 @@ env: DOTNET_VERSION: '9.x' VERSION: ${{ github.ref_name }} # Public, deliberately pinned identity. The strong-name counterpart lives in eng/IG.publickey.hex. - EXPECTED_CERT_SUBJECT_CN: 'Infragistics, Inc.' + EXPECTED_CERT_SHA256_PATH: 'eng/IG.authenticode-certificates.sha256' # Bound sbom-tool's external license lookup. SBOM_LICENSE_TIMEOUT_SECONDS: '180' @@ -136,8 +136,18 @@ jobs: throw "No DLLs found to validate." } + $allowedFingerprints = @( + Get-Content -LiteralPath $env:EXPECTED_CERT_SHA256_PATH | + ForEach-Object { $_.Trim().ToUpperInvariant() } | + Where-Object { $_ -and -not $_.StartsWith('#') } + ) + if ($allowedFingerprints.Count -eq 0 -or @($allowedFingerprints | Where-Object { $_ -notmatch '^[0-9A-F]{64}$' }).Count -gt 0) { + throw "$($env:EXPECTED_CERT_SHA256_PATH) must contain at least one valid SHA-256 certificate fingerprint." + } + $failed = @() - $thumbprints = @{} + $fingerprints = @{} + $signerNames = @{} foreach ($dll in $dlls) { $sig = Get-AuthenticodeSignature $dll.FullName if ($sig.Status -ne 'Valid') { @@ -145,25 +155,27 @@ jobs: continue } - # A valid signature only proves some trusted certificate was used, not the approved one. $cn = $sig.SignerCertificate.GetNameInfo('SimpleName', $false) - if ($cn -ne $env:EXPECTED_CERT_SUBJECT_CN) { - $failed += "$($dll.FullName): signed by '$cn', expected '$($env:EXPECTED_CERT_SUBJECT_CN)'." + $fingerprint = [Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($sig.SignerCertificate.RawData) + ) + if ($fingerprint -notin $allowedFingerprints) { + $failed += "$($dll.FullName): certificate SHA-256 fingerprint $fingerprint is not approved by '$($env:EXPECTED_CERT_SHA256_PATH)'." continue } - $thumbprints[$sig.SignerCertificate.Thumbprint] = $true + $fingerprints[$fingerprint] = $true + $signerNames[$cn] = $true } if ($failed.Count -gt 0) { throw "Authenticode validation failed:`n$($failed -join "`n")" } - Write-Host "All $($dlls.Count) DLLs signed by '$($env:EXPECTED_CERT_SUBJECT_CN)'." - + Write-Host "All $($dlls.Count) DLLs signed by an approved certificate." @( "### Authenticode signer", - "- Subject CN: $($env:EXPECTED_CERT_SUBJECT_CN)", - "- Thumbprint: $($thumbprints.Keys -join ', ')" + "- Subject CN: $($signerNames.Keys -join ', ')", + "- SHA-256 fingerprint: $($fingerprints.Keys -join ', ')" ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY - name: Upload signed assemblies @@ -478,7 +490,7 @@ jobs: user: ${{ secrets.INFRAGISTICS_NUGET_ORG_USER }} - name: Publish to NuGet.org - run: dotnet nuget push "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" + run: dotnet nuget push "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" --skip-duplicate attach-to-release: name: Attach release evidence diff --git a/eng/IG.authenticode-certificates.sha256 b/eng/IG.authenticode-certificates.sha256 new file mode 100644 index 0000000..01307c5 --- /dev/null +++ b/eng/IG.authenticode-certificates.sha256 @@ -0,0 +1,3 @@ +# Approved Authenticode signing certificates, one SHA-256 fingerprint per line. +# Fingerprints are computed over the certificate's DER-encoded RawData. +7F0D4484D1D3C797FDC85801CACE18DB5249D61AFFB17DA5C1644D8BEA24630D \ No newline at end of file From 8418abd60c692b60db9891212a6135bc8a94ffc5 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 12:35:57 +0300 Subject: [PATCH 12/22] Update the version of the sbom-tool --- .config/dotnet-tools.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d6d9df7..805555f 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,10 +3,11 @@ "isRoot": true, "tools": { "sign": { - "version": "0.9.1-beta.25379.1", + "version": "0.9.1-beta.26330.1", "commands": [ "sign" - ] + ], + "rollForward": false } } } From 39eebd069d7be63477d895fd6dbcc5eb19030604 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 12:36:38 +0300 Subject: [PATCH 13/22] Ignore the sbom dir --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 564fbeb..8592793 100644 --- a/.gitignore +++ b/.gitignore @@ -63,6 +63,9 @@ project.lock.json project.fragment.lock.json artifacts/ +# SBOMs produced by .github/scripts/New-Sbom.ps1 +sbom/ + # ASP.NET Scaffolding ScaffoldingReadMe.txt From 207e40f06aa4b77393eabce97030f336ef8e0dd8 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 12:37:38 +0300 Subject: [PATCH 14/22] Pin the dotnet major version and roll forward on the latest feature build --- global.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 global.json diff --git a/global.json b/global.json new file mode 100644 index 0000000..cdbb589 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "9.0.100", + "rollForward": "latestFeature" + } +} From c516a2a31b4e49db17d3e3f4e78bbdd774bc8957 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 12:38:24 +0300 Subject: [PATCH 15/22] Some tab character-trimming in the SqlGenerator class --- SqlGenerator.cs | 78 ++++++++++++++++++++++++------------------------- 1 file changed, 39 insertions(+), 39 deletions(-) diff --git a/SqlGenerator.cs b/SqlGenerator.cs index 8593d68..47e5461 100644 --- a/SqlGenerator.cs +++ b/SqlGenerator.cs @@ -9,7 +9,7 @@ public static class SqlGenerator public static string GenerateSql(Query query) { var selectClause = BuildSelectClause(query); - var whereClause = BuildWhereClause(query.FilteringOperands, query.Operator); + var whereClause = BuildWhereClause(query.FilteringOperands, query.Operator); return $"{selectClause} {whereClause};"; } @@ -28,54 +28,54 @@ private static string BuildWhereClause(QueryFilter[] filters, FilterType filterT return string.Empty; } - var conditions = filters.Select(BuildCondition).ToArray(); + var conditions = filters.Select(BuildCondition).ToArray(); var conjunction = filterType == FilterType.And ? " AND " : " OR "; return $"WHERE {string.Join(conjunction, conditions)}"; } private static string BuildCondition(QueryFilter filter) { - var field = filter.FieldName; + var field = filter.FieldName; var condition = filter.Condition?.Name; - var value = filter.SearchVal != null ? $"'{filter.SearchVal}'" : "NULL"; - var subquery = filter.SearchTree != null ? $"({GenerateSql(filter.SearchTree)})" : string.Empty; + var value = filter.SearchVal != null ? $"'{filter.SearchVal}'" : "NULL"; + var subquery = filter.SearchTree != null ? $"({GenerateSql(filter.SearchTree)})" : string.Empty; return condition switch { - "null" => $"{field} IS NULL", - "notNull" => $"{field} IS NOT NULL", - "empty" => $"{field} = ''", - "notEmpty" => $"{field} <> ''", - "equals" => $"{field} = {value}", - "doesNotEqual" => $"{field} <> {value}", - "in" => $"{field} IN ({value})", - "inQuery" => $"{field} IN ({subquery})", - "notInQuery" => $"{field} NOT IN ({subquery})", - "contains" => $"{field} LIKE '%{filter.SearchVal}%'", - "doesNotContain" => $"{field} NOT LIKE '%{filter.SearchVal}%'", - "startsWith" => $"{field} LIKE '{filter.SearchVal}%'", - "endsWith" => $"{field} LIKE '%{filter.SearchVal}'", - "greaterThan" => $"{field} > {value}", - "lessThan" => $"{field} < {value}", + "null" => $"{field} IS NULL", + "notNull" => $"{field} IS NOT NULL", + "empty" => $"{field} = ''", + "notEmpty" => $"{field} <> ''", + "equals" => $"{field} = {value}", + "doesNotEqual" => $"{field} <> {value}", + "in" => $"{field} IN ({value})", + "inQuery" => $"{field} IN ({subquery})", + "notInQuery" => $"{field} NOT IN ({subquery})", + "contains" => $"{field} LIKE '%{filter.SearchVal}%'", + "doesNotContain" => $"{field} NOT LIKE '%{filter.SearchVal}%'", + "startsWith" => $"{field} LIKE '{filter.SearchVal}%'", + "endsWith" => $"{field} LIKE '%{filter.SearchVal}'", + "greaterThan" => $"{field} > {value}", + "lessThan" => $"{field} < {value}", "greaterThanOrEqualTo" => $"{field} >= {value}", - "lessThanOrEqualTo" => $"{field} <= {value}", - "before" => $"{field} < {value}", - "after" => $"{field} > {value}", - "today" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}%'", - "yesterday" => $"{field} LIKE '{DateTime.Now.Date.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}%'", - "thisMonth" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", - "lastMonth" => $"{field} LIKE '{DateTime.Now.Date.AddMonths(-1).ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", - "nextMonth" => $"{field} LIKE '{DateTime.Now.Date.AddMonths(1).ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", - "thisYear" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy", CultureInfo.InvariantCulture)}%'", - "lastYear" => $"{field} LIKE '{DateTime.Now.Date.AddYears(-1).ToString("yyyy", CultureInfo.InvariantCulture)}%'", - "nextYear" => $"{field} LIKE '{DateTime.Now.Date.AddYears(1).ToString("yyyy", CultureInfo.InvariantCulture)}%'", - "at" => $"{field} = {value}", - "not_at" => $"{field} <> {value}", - "at_before" => $"{field} < {value}", - "at_after" => $"{field} > {value}", - "all" => "TRUE", - "true" => $"{field} = TRUE", - "false" => $"{field} = FALSE", - _ => $"{field} {condition} {value}", + "lessThanOrEqualTo" => $"{field} <= {value}", + "before" => $"{field} < {value}", + "after" => $"{field} > {value}", + "today" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}%'", + "yesterday" => $"{field} LIKE '{DateTime.Now.Date.AddDays(-1).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)}%'", + "thisMonth" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", + "lastMonth" => $"{field} LIKE '{DateTime.Now.Date.AddMonths(-1).ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", + "nextMonth" => $"{field} LIKE '{DateTime.Now.Date.AddMonths(1).ToString("yyyy-MM", CultureInfo.InvariantCulture)}%'", + "thisYear" => $"{field} LIKE '{DateTime.Now.Date.ToString("yyyy", CultureInfo.InvariantCulture)}%'", + "lastYear" => $"{field} LIKE '{DateTime.Now.Date.AddYears(-1).ToString("yyyy", CultureInfo.InvariantCulture)}%'", + "nextYear" => $"{field} LIKE '{DateTime.Now.Date.AddYears(1).ToString("yyyy", CultureInfo.InvariantCulture)}%'", + "at" => $"{field} = {value}", + "not_at" => $"{field} <> {value}", + "at_before" => $"{field} < {value}", + "at_after" => $"{field} > {value}", + "all" => "TRUE", + "true" => $"{field} = TRUE", + "false" => $"{field} = FALSE", + _ => $"{field} {condition} {value}", }; } } From 83678c41d70a88093f4c23ac822c354a56acb7ce Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 12:41:32 +0300 Subject: [PATCH 16/22] Apply lessons learned from the Blazor lite repos + Damyan preferences on his detested use of Powershell scripts in gh workflows --- .../scripts/Assert-AuthenticodeSignature.ps1 | 77 ++++ .github/scripts/Assert-NuspecRepository.ps1 | 130 ++++++ .github/scripts/Assert-PackageStrongName.ps1 | 39 ++ .github/scripts/Assert-Sbom.ps1 | 47 +++ .github/scripts/Copy-AttestationBundles.ps1 | 39 ++ .github/scripts/Get-PackageDigest.ps1 | 59 +++ .github/scripts/Invoke-DependencyScan.ps1 | 107 +++++ .github/scripts/New-Sbom.ps1 | 78 ++++ .github/scripts/Publish-NuGetPackage.ps1 | 69 ++++ .github/workflows/build-and-publish.yml | 375 ++++++++---------- .github/workflows/ci.yml | 41 +- .github/workflows/sbom.yml | 74 ++-- 12 files changed, 881 insertions(+), 254 deletions(-) create mode 100644 .github/scripts/Assert-AuthenticodeSignature.ps1 create mode 100644 .github/scripts/Assert-NuspecRepository.ps1 create mode 100644 .github/scripts/Assert-PackageStrongName.ps1 create mode 100644 .github/scripts/Assert-Sbom.ps1 create mode 100644 .github/scripts/Copy-AttestationBundles.ps1 create mode 100644 .github/scripts/Get-PackageDigest.ps1 create mode 100644 .github/scripts/Invoke-DependencyScan.ps1 create mode 100644 .github/scripts/New-Sbom.ps1 create mode 100644 .github/scripts/Publish-NuGetPackage.ps1 diff --git a/.github/scripts/Assert-AuthenticodeSignature.ps1 b/.github/scripts/Assert-AuthenticodeSignature.ps1 new file mode 100644 index 0000000..723c6ac --- /dev/null +++ b/.github/scripts/Assert-AuthenticodeSignature.ps1 @@ -0,0 +1,77 @@ +<# +.SYNOPSIS + Verifies that every assembly under the given path is Authenticode signed by an approved certificate. + +.DESCRIPTION + A valid Authenticode signature only proves that *someone* signed the file. This script additionally + requires the signer certificate's SHA-256 fingerprint to appear in a list pinned in the repository, + so a signature produced with any other certificate is rejected rather than trusted. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string[]]$Path, + + [Parameter(Mandatory)] + [string]$ExpectedCertificateSha256Path, + + [string]$SummaryPath = $env:GITHUB_STEP_SUMMARY +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $ExpectedCertificateSha256Path -PathType Leaf)) { + throw "Pinned certificate fingerprint file not found: $ExpectedCertificateSha256Path" +} + +$allowedFingerprints = @( + Get-Content -LiteralPath $ExpectedCertificateSha256Path | + ForEach-Object { $_.Trim().ToUpperInvariant() } | + Where-Object { $_ -and -not $_.StartsWith('#') } +) + +# A blank or malformed pin must fail loudly; otherwise the whole check silently becomes a no-op. +if ($allowedFingerprints.Count -eq 0 -or @($allowedFingerprints | Where-Object { $_ -notmatch '^[0-9A-F]{64}$' }).Count -gt 0) { + throw "$ExpectedCertificateSha256Path must contain at least one valid SHA-256 certificate fingerprint." +} + +$assemblies = @(Get-ChildItem -Path $Path -Filter '*.dll' -Recurse -File) +if ($assemblies.Count -eq 0) { + throw "No DLLs were found under '$($Path -join ', ')'. Refusing to report success." +} + +$problems = @() +$fingerprints = @{} +$signerNames = @{} +foreach ($assembly in $assemblies) { + $signature = Get-AuthenticodeSignature -LiteralPath $assembly.FullName + if ($signature.Status -ne 'Valid') { + $problems += "$($assembly.FullName): signature status $($signature.Status)." + continue + } + + $fingerprint = [Convert]::ToHexString( + [System.Security.Cryptography.SHA256]::HashData($signature.SignerCertificate.RawData) + ) + if ($fingerprint -notin $allowedFingerprints) { + $problems += "$($assembly.FullName): certificate SHA-256 fingerprint $fingerprint is not approved by '$ExpectedCertificateSha256Path'." + continue + } + + $fingerprints[$fingerprint] = $true + $signerNames[$signature.SignerCertificate.GetNameInfo('SimpleName', $false)] = $true +} + +if ($problems.Count -gt 0) { + throw "Authenticode validation failed:`n$($problems -join "`n")" +} + +Write-Host "All $($assemblies.Count) DLLs signed by an approved certificate." + +if ($SummaryPath) { + @( + '### Authenticode signer', + "- Subject CN: $($signerNames.Keys -join ', ')", + "- SHA-256 fingerprint: $($fingerprints.Keys -join ', ')" + ) | Add-Content -LiteralPath $SummaryPath +} diff --git a/.github/scripts/Assert-NuspecRepository.ps1 b/.github/scripts/Assert-NuspecRepository.ps1 new file mode 100644 index 0000000..d63606e --- /dev/null +++ b/.github/scripts/Assert-NuspecRepository.ps1 @@ -0,0 +1,130 @@ +<# +.SYNOPSIS + Asserts a packed NuGet package carries the provenance metadata consumers rely on. + +.DESCRIPTION + The nuspec is generated from MSBuild properties at pack time, so an unset one yields a package that + restores fine but cannot be traced back to source. This fails the release instead of shipping it. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackagePath, + + [Parameter(Mandatory)] + [string]$ExpectedRepositoryUrl, + + [Parameter(Mandatory)] + [string]$ExpectedCommit, + + [string]$ExpectedPackageId, + + [string]$ExpectedVersion +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "NuGet package not found: $PackagePath" +} + +if ($ExpectedCommit -notmatch '^[0-9a-fA-F]{40}$') { + throw "ExpectedCommit must be a full 40-character git SHA, but was '$ExpectedCommit'." +} + +Add-Type -AssemblyName System.IO.Compression.FileSystem + +$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath).ProviderPath) +try { + $entry = $archive.Entries | + Where-Object { $_.FullName -notlike '*/*' -and $_.FullName -like '*.nuspec' } | + Select-Object -First 1 + + if ($null -eq $entry) { + throw "No .nuspec found at the root of $PackagePath." + } + + $reader = New-Object System.IO.StreamReader($entry.Open()) + try { + $nuspecXml = $reader.ReadToEnd() + } + finally { + $reader.Dispose() + } +} +finally { + $archive.Dispose() +} + +$document = New-Object System.Xml.XmlDocument +$document.PreserveWhitespace = $false +$document.LoadXml($nuspecXml) + +# The nuspec default namespace changes with the schema version, so match on local names only. +$metadata = $document.SelectSingleNode('/*[local-name()="package"]/*[local-name()="metadata"]') +if ($null -eq $metadata) { + throw "The nuspec in $PackagePath has no element." +} + +function Get-MetadataValue([string]$Name) { + $node = $metadata.SelectSingleNode("*[local-name()=`"$Name`"]") + if ($null -eq $node) { return $null } + return $node.InnerText.Trim() +} + +$problems = @() + +function Assert-Value([string]$Label, [string]$Actual, [string]$Expected) { + if ([string]::IsNullOrWhiteSpace($Actual)) { + $script:problems += "$Label is missing from the nuspec." + } + elseif ($Expected -and $Actual -ne $Expected) { + $script:problems += "$Label is '$Actual', expected '$Expected'." + } +} + +$repository = $metadata.SelectSingleNode('*[local-name()="repository"]') +if ($null -eq $repository) { + $problems += ' is missing from the nuspec.' +} +else { + Assert-Value 'repository/@type' $repository.GetAttribute('type') 'git' + Assert-Value 'repository/@url' $repository.GetAttribute('url') $ExpectedRepositoryUrl + Assert-Value 'repository/@commit' $repository.GetAttribute('commit') $ExpectedCommit +} + +Assert-Value 'authors' (Get-MetadataValue 'authors') $null +Assert-Value 'projectUrl' (Get-MetadataValue 'projectUrl') $null +Assert-Value 'description' (Get-MetadataValue 'description') $null +Assert-Value 'copyright' (Get-MetadataValue 'copyright') $null + +if ($ExpectedPackageId) { + Assert-Value 'id' (Get-MetadataValue 'id') $ExpectedPackageId +} + +if ($ExpectedVersion) { + Assert-Value 'version' (Get-MetadataValue 'version') $ExpectedVersion +} + +$license = $metadata.SelectSingleNode('*[local-name()="license"]') +if ($null -eq $license) { + $problems += ' is missing from the nuspec.' +} +elseif ($license.GetAttribute('type') -ne 'expression') { + $problems += "license/@type is '$($license.GetAttribute('type'))', expected 'expression'." +} + +# 'authors' defaults to the assembly name when is unset, which is not an author. +$authors = Get-MetadataValue 'authors' +if ($authors -and $ExpectedPackageId -and $authors -eq $ExpectedPackageId) { + $problems += "authors is '$authors', which is the package id rather than a real author. Set in the project file." +} + +if ($problems.Count -gt 0) { + throw "Package provenance metadata validation failed for $([System.IO.Path]::GetFileName($PackagePath)):`n- $($problems -join "`n- ")" +} + +Write-Host "Verified nuspec provenance for $([System.IO.Path]::GetFileName($PackagePath)):" +Write-Host " repository url : $($repository.GetAttribute('url'))" +Write-Host " repository commit : $($repository.GetAttribute('commit'))" +Write-Host " authors : $authors" diff --git a/.github/scripts/Assert-PackageStrongName.ps1 b/.github/scripts/Assert-PackageStrongName.ps1 new file mode 100644 index 0000000..f1d47d0 --- /dev/null +++ b/.github/scripts/Assert-PackageStrongName.ps1 @@ -0,0 +1,39 @@ +<# +.SYNOPSIS + Verifies the strong names of the assemblies inside a packed NuGet package. + +.DESCRIPTION + The assemblies validated after signing are the ones in bin/. This extracts the package that will + actually ship and re-checks those bytes, so a pack step that picked up a different build is caught. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackagePath, + + [Parameter(Mandatory)] + [string]$ExpectedPublicKeyPath, + + [string]$WorkingDirectory = (Join-Path ([System.IO.Path]::GetTempPath()) 'strong-name-validation') +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "NuGet package not found: $PackagePath" +} + +$extractPath = Join-Path $WorkingDirectory 'package' +# Expand-Archive only accepts .zip, so the package is copied under a name it will open. +$archivePath = Join-Path $WorkingDirectory 'package.zip' + +try { + New-Item -ItemType Directory -Path $WorkingDirectory -Force | Out-Null + Copy-Item -LiteralPath $PackagePath -Destination $archivePath -Force + Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force + + & (Join-Path $PSScriptRoot 'verify-strong-name.ps1') -Path $extractPath -ExpectedPublicKeyPath $ExpectedPublicKeyPath +} +finally { + Remove-Item -LiteralPath $WorkingDirectory -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/.github/scripts/Assert-Sbom.ps1 b/.github/scripts/Assert-Sbom.ps1 new file mode 100644 index 0000000..c917e00 --- /dev/null +++ b/.github/scripts/Assert-Sbom.ps1 @@ -0,0 +1,47 @@ +<# +.SYNOPSIS + Asserts the generated SPDX 2.2 and 3.0 manifests exist, are non-empty, and describe the shipped package. + +.DESCRIPTION + sbom-tool exits 0 for a document that lists nothing useful, so the release gate is this check rather + than the tool's exit code. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$OutputRoot, + + # The nupkg that must appear in the SPDX 2.2 files section; omit to only check the documents exist. + [string]$ExpectedFileName +) + +$ErrorActionPreference = 'Stop' + +$manifests = @( + (Join-Path $OutputRoot 'spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json'), + (Join-Path $OutputRoot 'spdx-3.0/_manifest/spdx_3.0/manifest.spdx.json') +) + +$problems = @() +foreach ($manifest in $manifests) { + foreach ($file in @($manifest, "$manifest.sha256")) { + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { + $problems += "SBOM file missing: $file" + } + elseif ((Get-Item -LiteralPath $file).Length -eq 0) { + $problems += "SBOM file is empty: $file" + } + } +} + +if ($problems.Count -gt 0) { + throw "SBOM validation failed:`n- $($problems -join "`n- ")" +} + +$spdx = Get-Content -LiteralPath $manifests[0] -Raw | ConvertFrom-Json + +if ($ExpectedFileName -and -not ($spdx.files | Where-Object { $_.fileName -like "*$ExpectedFileName" })) { + throw "The SPDX 2.2 document does not reference $ExpectedFileName." +} + +Write-Host "SBOM covers $($spdx.packages.Count) packages and $($spdx.files.Count) files." diff --git a/.github/scripts/Copy-AttestationBundles.ps1 b/.github/scripts/Copy-AttestationBundles.ps1 new file mode 100644 index 0000000..1f58898 --- /dev/null +++ b/.github/scripts/Copy-AttestationBundles.ps1 @@ -0,0 +1,39 @@ +<# +.SYNOPSIS + Copies the provenance and SBOM attestation bundles next to the SBOM documents and records their URLs. + +.DESCRIPTION + actions/attest writes each bundle to a temporary path that only the producing job can read, so the + bundles are collected into the release artifact while they are still reachable. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$ProvenanceBundlePath, + + [Parameter(Mandatory)] + [string]$SbomBundlePath, + + [Parameter(Mandatory)] + [string]$Destination, + + [string]$ProvenanceUrl, + + [string]$SbomUrl, + + [string]$SummaryPath = $env:GITHUB_STEP_SUMMARY +) + +$ErrorActionPreference = 'Stop' + +New-Item -ItemType Directory -Path $Destination -Force | Out-Null +Copy-Item -LiteralPath $ProvenanceBundlePath -Destination (Join-Path $Destination 'provenance.sigstore.json') -Force +Copy-Item -LiteralPath $SbomBundlePath -Destination (Join-Path $Destination 'sbom.sigstore.json') -Force + +if ($SummaryPath) { + @( + '### Attestations', + "- Provenance: $ProvenanceUrl", + "- SBOM: $SbomUrl" + ) | Add-Content -LiteralPath $SummaryPath +} diff --git a/.github/scripts/Get-PackageDigest.ps1 b/.github/scripts/Get-PackageDigest.ps1 new file mode 100644 index 0000000..e1dd126 --- /dev/null +++ b/.github/scripts/Get-PackageDigest.ps1 @@ -0,0 +1,59 @@ +<# +.SYNOPSIS + Computes a package's SHA-256 digest and, optionally, asserts it against digests recorded earlier. + +.DESCRIPTION + The digest is the identity every downstream job re-checks before acting on the package, so that a + job cannot pack, attest or publish bytes other than the ones that were signed. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackagePath, + + # Every value must match the computed digest. Empty entries are ignored so a caller can pass + # job outputs directly without branching on which of them are set. + [string[]]$ExpectedSha256 = @(), + + [string]$FailureMessage = 'Package digest changed between jobs.', + + # Writes ' ' next to the package, in the format sha256sum expects. + [switch]$WriteChecksumFile, + + [string]$GitHubOutputName, + + [string]$SummaryTitle, + + [string]$GitHubOutputPath = $env:GITHUB_OUTPUT, + + [string]$SummaryPath = $env:GITHUB_STEP_SUMMARY +) + +$ErrorActionPreference = 'Stop' + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "NuGet package not found: $PackagePath" +} + +$name = [System.IO.Path]::GetFileName($PackagePath) +$digest = (Get-FileHash -LiteralPath $PackagePath -Algorithm SHA256).Hash.ToLowerInvariant() + +foreach ($expected in @($ExpectedSha256 | Where-Object { $_ })) { + if ($digest -ne $expected.Trim().ToLowerInvariant()) { + throw "$FailureMessage Expected $expected but found $digest for $name." + } +} + +if ($WriteChecksumFile) { + "$digest $name" | Set-Content -LiteralPath "$PackagePath.sha256" -Encoding ascii +} + +if ($GitHubOutputName -and $GitHubOutputPath) { + "$GitHubOutputName=$digest" | Add-Content -LiteralPath $GitHubOutputPath +} + +if ($SummaryTitle -and $SummaryPath) { + @("### $SummaryTitle", '```', "$digest $name", '```') | Add-Content -LiteralPath $SummaryPath +} + +Write-Host "Verified $name with digest $digest." diff --git a/.github/scripts/Invoke-DependencyScan.ps1 b/.github/scripts/Invoke-DependencyScan.ps1 new file mode 100644 index 0000000..968136d --- /dev/null +++ b/.github/scripts/Invoke-DependencyScan.ps1 @@ -0,0 +1,107 @@ +<# +.SYNOPSIS + Records the known vulnerabilities in the dependencies the package ships. + +.DESCRIPTION + Advisory by design. A finding is annotated and attached to the release as evidence but never holds + up the publish; the blocking gate for newly introduced vulnerable dependencies is the + dependency-review job on pull requests, which stops them entering master. A scan that fails to run, + however, is an error: silence must not be mistaken for a clean result. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$ProjectPath, + + [Parameter(Mandatory)] + [string]$OutputDirectory, + + [string]$PackageId, + + [string]$Version, + + [string]$SummaryPath = $env:GITHUB_STEP_SUMMARY +) + +$ErrorActionPreference = 'Stop' +# The scan's exit code is inspected explicitly so a failure can be reported with context. +$PSNativeCommandUseErrorActionPreference = $false + +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null +$reportPath = Join-Path $OutputDirectory 'nuget-vulnerable.json' + +dotnet restore $ProjectPath | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "dotnet restore failed for $ProjectPath with exit code $LASTEXITCODE." +} + +dotnet list $ProjectPath package --vulnerable --include-transitive --format json --output-version 1 | + Set-Content -LiteralPath $reportPath -Encoding utf8 +if ($LASTEXITCODE -ne 0) { + throw "dotnet list package --vulnerable failed with exit code $LASTEXITCODE." +} + +$report = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json +if ($report.version -ne 1 -or -not $report.projects) { + throw 'dotnet list package did not produce a valid version 1 JSON report.' +} + +$findings = @( + foreach ($project in $report.projects) { + # A framework with no findings omits the package arrays entirely, so null entries are dropped. + foreach ($framework in @($project.frameworks | Where-Object { $_ })) { + $packages = @($framework.topLevelPackages) + @($framework.transitivePackages) + foreach ($package in @($packages | Where-Object { $_ })) { + foreach ($vulnerability in @($package.vulnerabilities | Where-Object { $_ })) { + [pscustomobject]@{ + Framework = $framework.framework + Package = $package.id + Resolved = $package.resolvedVersion + Severity = $vulnerability.severity + AdvisoryUrl = $vulnerability.advisoryurl + } + } + } + } + } +) + +$table = if ($findings.Count -gt 0) { + $findings | Sort-Object Severity, Package, Framework | Format-Table -AutoSize | Out-String -Width 200 +} +else { + 'No vulnerable shipped dependencies reported.' +} + +$table | Set-Content -LiteralPath (Join-Path $OutputDirectory 'nuget-vulnerable.txt') -Encoding utf8 +Write-Host $table + +if (-not $SummaryPath) { + return +} + +$summary = @( + '### Dependency vulnerability scan' + '' + 'Advisory only — findings are recorded but do not block this release.' + '' + '
dotnet list package --vulnerable --include-transitive' + '' + '```' + $table.TrimEnd() + '```' + '' + '
' + '' +) + +if ($findings.Count -gt 0) { + $label = "$PackageId $Version".Trim() + Write-Host "::warning title=Vulnerable dependencies reported::$label was released with $($findings.Count) dependency advisories outstanding. See the run summary and the dependency-scan release asset." + $summary += "> [!WARNING]`n> $($findings.Count) vulnerable dependencies were reported for this release. Review the scan output above and open a servicing issue if a fix is required." +} +else { + $summary += '> ✅ No vulnerable shipped dependencies reported.' +} + +$summary | Add-Content -LiteralPath $SummaryPath diff --git a/.github/scripts/New-Sbom.ps1 b/.github/scripts/New-Sbom.ps1 new file mode 100644 index 0000000..62bdcb3 --- /dev/null +++ b/.github/scripts/New-Sbom.ps1 @@ -0,0 +1,78 @@ +<# +.SYNOPSIS + Generates SPDX 2.2 and SPDX 3.0 SBOMs for a packed NuGet package with the pinned sbom-tool. + +.DESCRIPTION + Shared by the release workflow and the on-demand SBOM dry run so both produce identical documents. + 'dotnet tool run' has no --tool-manifest option and discovers the manifest from the current + directory, so the tool is invoked from the folder holding the nested manifest. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackageId, + + [Parameter(Mandatory)] + [string]$PackageVersion, + + # -b: files under this path are hashed into the SBOM's files section. + [Parameter(Mandatory)] + [string]$BuildDropPath, + + # -bc: root the component detectors scan to build the dependency graph. + [Parameter(Mandatory)] + [string]$BuildComponentPath, + + [Parameter(Mandatory)] + [string]$OutputRoot, + + [string]$Supplier = 'Infragistics Inc.', + + [string]$NamespaceBaseUri, + + # External license lookup is a network call per component; bound it or turn it off. + [bool]$ResolveLicenses = $true, + + [int]$LicenseTimeoutSeconds = 180, + + [string]$ToolManifestDirectory = (Join-Path $PSScriptRoot '../../.config/sbom-tool') +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +if (-not $NamespaceBaseUri) { + $NamespaceBaseUri = "http://spdx.org/spdxdocs/$PackageId" +} + +$formats = @( + @{ Version = 'SPDX:2.2'; Output = 'spdx-2.2' }, + @{ Version = 'SPDX:3.0'; Output = 'spdx-3.0' } +) + +Push-Location -LiteralPath $ToolManifestDirectory +try { + foreach ($format in $formats) { + $manifestDirectory = Join-Path $OutputRoot $format.Output + # sbom-tool requires the manifest directory to exist before generation. + New-Item -ItemType Directory -Path $manifestDirectory -Force | Out-Null + + Write-Host "Generating $($format.Version) SBOM into $manifestDirectory..." + dotnet tool run sbom-tool -- generate ` + -b $BuildDropPath ` + -bc $BuildComponentPath ` + -m $manifestDirectory ` + -pn $PackageId ` + -pv $PackageVersion ` + -ps $Supplier ` + -nsb $NamespaceBaseUri ` + -mi $format.Version ` + -li $ResolveLicenses.ToString().ToLowerInvariant() ` + -lto $LicenseTimeoutSeconds ` + -pm true ` + -V Information + } +} +finally { + Pop-Location +} diff --git a/.github/scripts/Publish-NuGetPackage.ps1 b/.github/scripts/Publish-NuGetPackage.ps1 new file mode 100644 index 0000000..5b0be29 --- /dev/null +++ b/.github/scripts/Publish-NuGetPackage.ps1 @@ -0,0 +1,69 @@ +<# +.SYNOPSIS + Pushes the signed package to NuGet.org, refusing to overwrite a version that is already published. + +.DESCRIPTION + Deliberately not --skip-duplicate: a rerun produces newly signed bytes, so skipping the duplicate + would attach this run's SBOM, attestations and checksum to a release whose published package is a + different build. The API key is read from NUGET_API_KEY so it never appears in a command line. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackagePath, + + [Parameter(Mandatory)] + [string]$PackageId, + + [Parameter(Mandatory)] + [string]$Version, + + [string]$Source = 'https://api.nuget.org/v3/index.json', + + [string]$FlatContainerBaseUrl = 'https://api.nuget.org/v3-flatcontainer' +) + +$ErrorActionPreference = 'Stop' +# Pinned: a failed push is inspected through $LASTEXITCODE below rather than terminating the script. +$PSNativeCommandUseErrorActionPreference = $false + +if ([string]::IsNullOrWhiteSpace($env:NUGET_API_KEY)) { + throw 'NUGET_API_KEY is not set. The publish step must expose the token from the NuGet login step.' +} + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "NuGet package not found: $PackagePath" +} + +$id = $PackageId.ToLowerInvariant() +$normalizedVersion = $Version.ToLowerInvariant() +$feedUrl = "$FlatContainerBaseUrl/$id/$normalizedVersion/$id.$normalizedVersion.nupkg" + +# The flat container lags a push by seconds to minutes, so the retries stop a duplicate +# rejection from being reported as a package that never reached the feed. +function Test-Published([int[]]$RetryDelaysSeconds = @()) { + foreach ($delay in @(0) + $RetryDelaysSeconds) { + if ($delay -gt 0) { Start-Sleep -Seconds $delay } + if ((Invoke-WebRequest -Uri $feedUrl -Method Head -SkipHttpErrorCheck).StatusCode -eq 200) { return $true } + } + return $false +} + +$recovery = "NuGet.org will not accept this version again. If a previous run published it but failed before attaching evidence, attach that run's retained nupkg-signed and sbom artifacts to the release manually." + +if (Test-Published) { + throw "$PackageId $Version is already on NuGet.org, so this run must not attach its evidence to the release: the published package may be a different build. $recovery" +} + +dotnet nuget push $PackagePath --api-key $env:NUGET_API_KEY --source $Source +if ($LASTEXITCODE -eq 0) { + Write-Host "Published $PackageId $Version." + exit 0 +} + +Write-Host "::warning title=Push reported a failure::Checking whether the package reached NuGet.org anyway." +if (Test-Published -RetryDelaysSeconds @(5, 15, 30)) { + throw "dotnet nuget push reported a failure but $PackageId $Version is on NuGet.org. $recovery" +} + +throw "dotnet nuget push failed and $PackageId $Version is not on NuGet.org." diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 41713d7..bb57650 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -13,8 +13,12 @@ env: BUILD_CONFIGURATION: Release DOTNET_VERSION: '9.x' VERSION: ${{ github.ref_name }} - # Public, deliberately pinned identity. The strong-name counterpart lives in eng/IG.publickey.hex. + PACKAGE_ID: Infragistics.QueryBuilder.Executor + PROJECT_PATH: Infragistics.QueryBuilder.Executor.csproj + REPOSITORY_URL: https://github.com/IgniteUI/Infragistics.QueryBuilder.Executor + # Public, deliberately pinned identities. EXPECTED_CERT_SHA256_PATH: 'eng/IG.authenticode-certificates.sha256' + EXPECTED_PUBLIC_KEY_PATH: 'eng/IG.publickey.hex' # Bound sbom-tool's external license lookup. SBOM_LICENSE_TIMEOUT_SECONDS: '180' @@ -29,7 +33,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -39,7 +43,7 @@ jobs: dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore .NET dependencies - run: dotnet restore Infragistics.QueryBuilder.Executor.csproj + run: dotnet restore ${{ env.PROJECT_PATH }} - name: Restore strong-name key shell: pwsh @@ -55,13 +59,14 @@ jobs: - name: Build strong-named assemblies shell: pwsh - run: | - dotnet build Infragistics.QueryBuilder.Executor.csproj ` - --configuration ${{ env.BUILD_CONFIGURATION }} ` - --no-restore ` - -p:Version=$env:VERSION ` - -p:SignAssembly=true ` - -p:AssemblyOriginatorKeyFile="${{ runner.temp }}\IG.StrongName.snk" + run: > + dotnet build ${{ env.PROJECT_PATH }} + --configuration ${{ env.BUILD_CONFIGURATION }} + --no-restore + -p:Version=${{ env.VERSION }} + -p:ContinuousIntegrationBuild=true + -p:SignAssembly=true + -p:AssemblyOriginatorKeyFile="${{ runner.temp }}\IG.StrongName.snk" - name: Delete strong-name key if: always() @@ -92,7 +97,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -112,7 +117,7 @@ jobs: run: dotnet tool restore - name: Authenticate to Azure - uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 + uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -130,53 +135,10 @@ jobs: - name: Validate DLL signatures shell: pwsh - run: | - $dlls = @(Get-ChildItem -Path "${{ github.workspace }}\bin\${{ env.BUILD_CONFIGURATION }}" -Filter '*.dll' -Recurse -File) - if ($dlls.Count -eq 0) { - throw "No DLLs found to validate." - } - - $allowedFingerprints = @( - Get-Content -LiteralPath $env:EXPECTED_CERT_SHA256_PATH | - ForEach-Object { $_.Trim().ToUpperInvariant() } | - Where-Object { $_ -and -not $_.StartsWith('#') } - ) - if ($allowedFingerprints.Count -eq 0 -or @($allowedFingerprints | Where-Object { $_ -notmatch '^[0-9A-F]{64}$' }).Count -gt 0) { - throw "$($env:EXPECTED_CERT_SHA256_PATH) must contain at least one valid SHA-256 certificate fingerprint." - } - - $failed = @() - $fingerprints = @{} - $signerNames = @{} - foreach ($dll in $dlls) { - $sig = Get-AuthenticodeSignature $dll.FullName - if ($sig.Status -ne 'Valid') { - $failed += "$($dll.FullName): signature status $($sig.Status)." - continue - } - - $cn = $sig.SignerCertificate.GetNameInfo('SimpleName', $false) - $fingerprint = [Convert]::ToHexString( - [System.Security.Cryptography.SHA256]::HashData($sig.SignerCertificate.RawData) - ) - if ($fingerprint -notin $allowedFingerprints) { - $failed += "$($dll.FullName): certificate SHA-256 fingerprint $fingerprint is not approved by '$($env:EXPECTED_CERT_SHA256_PATH)'." - continue - } - - $fingerprints[$fingerprint] = $true - $signerNames[$cn] = $true - } - if ($failed.Count -gt 0) { - throw "Authenticode validation failed:`n$($failed -join "`n")" - } - - Write-Host "All $($dlls.Count) DLLs signed by an approved certificate." - @( - "### Authenticode signer", - "- Subject CN: $($signerNames.Keys -join ', ')", - "- SHA-256 fingerprint: $($fingerprints.Keys -join ', ')" - ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + run: > + .github/scripts/Assert-AuthenticodeSignature.ps1 + -Path "${{ github.workspace }}/bin/${{ env.BUILD_CONFIGURATION }}" + -ExpectedCertificateSha256Path "${{ env.EXPECTED_CERT_SHA256_PATH }}" - name: Upload signed assemblies uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -203,7 +165,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -219,43 +181,43 @@ jobs: path: . digest-mismatch: error + # Repository url and commit are passed explicitly so the nuspec never carries a commit + # without a url (or vice versa) depending on what the checkout leaves in .git. - name: Pack NuGet package run: > - dotnet pack Infragistics.QueryBuilder.Executor.csproj + dotnet pack ${{ env.PROJECT_PATH }} --configuration ${{ env.BUILD_CONFIGURATION }} --no-build --no-restore - -p:PackageVersion=$env:VERSION - -o "${{ github.workspace }}/nupkg" + -p:PackageVersion=${{ env.VERSION }} + -p:RepositoryUrl=${{ env.REPOSITORY_URL }} + -p:RepositoryType=git + -p:RepositoryCommit=${{ github.sha }} + -o "${{ github.workspace }}/artifacts" - name: Validate packaged assembly strong names shell: pwsh - run: | - $packagePath = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" - $validationRoot = "${{ runner.temp }}\strong-name-validation" - $archivePath = "$validationRoot\package.zip" - $extractPath = "$validationRoot\package" - - try { - if (-not (Test-Path $packagePath)) { - throw "NuGet package not found: $packagePath" - } - - New-Item -ItemType Directory -Path $validationRoot -Force | Out-Null - Copy-Item $packagePath $archivePath - Expand-Archive -Path $archivePath -DestinationPath $extractPath -Force + run: > + .github/scripts/Assert-PackageStrongName.ps1 + -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -ExpectedPublicKeyPath "${{ env.EXPECTED_PUBLIC_KEY_PATH }}" + -WorkingDirectory "${{ runner.temp }}/strong-name-validation" - .github/scripts/verify-strong-name.ps1 -Path $extractPath -ExpectedPublicKeyPath eng/IG.publickey.hex - } - finally { - Remove-Item $validationRoot -Recurse -Force -ErrorAction SilentlyContinue - } + - name: Validate package provenance metadata + shell: pwsh + run: > + .github/scripts/Assert-NuspecRepository.ps1 + -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -ExpectedRepositoryUrl "${{ env.REPOSITORY_URL }}" + -ExpectedCommit "${{ github.sha }}" + -ExpectedPackageId "${{ env.PACKAGE_ID }}" + -ExpectedVersion "${{ env.VERSION }}" - name: Restore .NET local tools run: dotnet tool restore - name: Authenticate to Azure - uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 + uses: azure/login@f5d393ae46f8fde4be8b75f32e3fc50e654ad0ca # v3.0.1 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -265,7 +227,7 @@ jobs: shell: pwsh run: > dotnet tool run sign code azure-key-vault "*.nupkg" - --base-directory "${{ github.workspace }}/nupkg" + --base-directory "${{ github.workspace }}/artifacts" --azure-key-vault-url "${{ secrets.AZURE_KEYVAULT_URL }}" --azure-key-vault-certificate "${{ secrets.AZURE_KEYVAULT_CERTIFICATE }}" --azure-credential-type azure-cli @@ -273,28 +235,62 @@ jobs: --verbosity Warning - name: Validate NuGet package signature - run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --verbosity quiet + run: dotnet nuget verify "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" --verbosity quiet # This digest is the identity every downstream job re-checks before acting on the package. - name: Record package digest id: digest shell: pwsh - run: | - $name = "Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" - $package = "${{ github.workspace }}\nupkg\$name" - $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() - - "$digest $name" | Set-Content -LiteralPath "$package.sha256" -Encoding ascii - "nupkg-sha256=$digest" | Add-Content -LiteralPath $env:GITHUB_OUTPUT - - @("### Signed package digest", '```', "$digest $name", '```') | - Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + run: > + .github/scripts/Get-PackageDigest.ps1 + -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -WriteChecksumFile + -GitHubOutputName 'nupkg-sha256' + -SummaryTitle 'Signed package digest' - name: Upload signed package uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: nupkg-signed - path: nupkg/* + path: artifacts/* + retention-days: 30 + if-no-files-found: error + + # Advisory by design. A finding is annotated and attached to the release as evidence, but never + # holds up the publish — the blocking gate for newly introduced vulnerable dependencies is the + # dependency-review job on pull requests, which stops them entering master. + dependency-scan: + name: Scan dependencies + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup .NET + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Scan NuGet dependencies + shell: pwsh + run: > + .github/scripts/Invoke-DependencyScan.ps1 + -ProjectPath "${{ env.PROJECT_PATH }}" + -OutputDirectory "${{ github.workspace }}/artifacts/dependency-scan" + -PackageId "${{ env.PACKAGE_ID }}" + -Version "${{ env.VERSION }}" + + - name: Upload dependency scan + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dependency-scan + path: artifacts/dependency-scan/* retention-days: 30 if-no-files-found: error @@ -308,11 +304,11 @@ jobs: id-token: write attestations: write outputs: - attested-sha256: ${{ steps.verify.outputs.nupkg-sha256 }} + attested-sha256: ${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -325,112 +321,77 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: nupkg-signed - path: nupkg + path: artifacts - name: Verify signed package digest - id: verify shell: pwsh - env: - EXPECTED_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} - run: | - $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" - $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() - if ($digest -ne $env:EXPECTED_NUPKG_SHA256) { - throw "Package digest changed between jobs. Expected $($env:EXPECTED_NUPKG_SHA256) but found $digest." - } - - "nupkg-sha256=$digest" | Add-Content -LiteralPath $env:GITHUB_OUTPUT - Write-Host "Verified package digest $digest." + run: > + .github/scripts/Get-PackageDigest.ps1 + -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -ExpectedSha256 "${{ needs.pack.outputs.nupkg-sha256 }}" - name: Restore .NET dependencies - run: dotnet restore Infragistics.QueryBuilder.Executor.csproj + run: dotnet restore ${{ env.PROJECT_PATH }} # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore' used by the sign steps. - name: Restore sbom-tool (pinned) run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json - # -b is the signed package folder, so the shipped nupkg and its hash land in the SBOM's files section. - # -bc scans the repository for the dependency graph; -li/-pm resolve license and supplier metadata. - name: Generate SBOMs - working-directory: .config/sbom-tool shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $PSNativeCommandUseErrorActionPreference = $true - - $formats = @( - @{ Version = 'SPDX:2.2'; Output = 'spdx-2.2' }, - @{ Version = 'SPDX:3.0'; Output = 'spdx-3.0' } - ) - - foreach ($format in $formats) { - $manifestDirectory = Join-Path $env:GITHUB_WORKSPACE "sbom\$($format.Output)" - # sbom-tool requires the manifest directory to exist before generation. - New-Item -ItemType Directory -Path $manifestDirectory -Force | Out-Null - - Write-Host "Generating $($format.Version) SBOM..." - dotnet tool run sbom-tool -- generate ` - -b "$env:GITHUB_WORKSPACE\nupkg" ` - -bc $env:GITHUB_WORKSPACE ` - -m $manifestDirectory ` - -pn Infragistics.QueryBuilder.Executor ` - -pv $env:VERSION ` - -ps "Infragistics Inc." ` - -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor ` - -mi $format.Version ` - -li true ` - -lto $env:SBOM_LICENSE_TIMEOUT_SECONDS ` - -pm true ` - -V Information - } + run: > + .github/scripts/New-Sbom.ps1 + -PackageId "${{ env.PACKAGE_ID }}" + -PackageVersion "${{ env.VERSION }}" + -BuildDropPath "${{ github.workspace }}/artifacts" + -BuildComponentPath "${{ github.workspace }}" + -OutputRoot "${{ github.workspace }}/sbom" + -LicenseTimeoutSeconds ${{ env.SBOM_LICENSE_TIMEOUT_SECONDS }} - name: Verify SBOM output shell: pwsh - run: | - $name = "Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" - $spdx22 = "${{ github.workspace }}\sbom\spdx-2.2\_manifest\spdx_2.2\manifest.spdx.json" - $spdx30 = "${{ github.workspace }}\sbom\spdx-3.0\_manifest\spdx_3.0\manifest.spdx.json" - - foreach ($manifestPath in @($spdx22, $spdx30)) { - if (-not (Test-Path -LiteralPath $manifestPath) -or (Get-Item -LiteralPath $manifestPath).Length -eq 0) { - throw "SBOM manifest missing or empty: $manifestPath" - } - } - - $spdx = Get-Content -LiteralPath $spdx22 -Raw | ConvertFrom-Json - if (-not ($spdx.files | Where-Object { $_.fileName -like "*$name" })) { - throw "The SPDX 2.2 document does not reference $name." - } - - Write-Host "SBOM covers $($spdx.packages.Count) packages and $($spdx.files.Count) files." + run: > + .github/scripts/Assert-Sbom.ps1 + -OutputRoot "${{ github.workspace }}/sbom" + -ExpectedFileName "${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + + # An attestation binds to a digest rather than a path, so the bytes are re-hashed here and that + # value is what gets attested, re-checked before publishing, and reported on the release. + - name: Reverify package before attestation + id: verify-before-attestation + shell: pwsh + run: > + .github/scripts/Get-PackageDigest.ps1 + -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -ExpectedSha256 "${{ needs.pack.outputs.nupkg-sha256 }}" + -FailureMessage 'Package changed before attestation.' + -GitHubOutputName 'nupkg-sha256' - name: Attest build provenance id: attest-provenance uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: - subject-path: nupkg/*.nupkg + subject-name: ${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg + subject-digest: sha256:${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} # actions/attest derives the predicate from an SPDX 2.x or CycloneDX document; SPDX 3.0 ships as evidence only. - name: Attest SBOM id: attest-sbom uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: - subject-path: nupkg/*.nupkg + subject-name: ${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg + subject-digest: sha256:${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} sbom-path: sbom/spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json - name: Collect attestation bundles shell: pwsh - run: | - $target = "${{ github.workspace }}\sbom\attestations" - New-Item -ItemType Directory -Path $target -Force | Out-Null - Copy-Item "${{ steps.attest-provenance.outputs.bundle-path }}" (Join-Path $target 'provenance.sigstore.json') - Copy-Item "${{ steps.attest-sbom.outputs.bundle-path }}" (Join-Path $target 'sbom.sigstore.json') - - @( - "### Attestations", - "- Provenance: ${{ steps.attest-provenance.outputs.attestation-url }}", - "- SBOM: ${{ steps.attest-sbom.outputs.attestation-url }}" - ) | Add-Content -LiteralPath $env:GITHUB_STEP_SUMMARY + run: > + .github/scripts/Copy-AttestationBundles.ps1 + -ProvenanceBundlePath "${{ steps.attest-provenance.outputs.bundle-path }}" + -SbomBundlePath "${{ steps.attest-sbom.outputs.bundle-path }}" + -Destination "${{ github.workspace }}/sbom/attestations" + -ProvenanceUrl "${{ steps.attest-provenance.outputs.attestation-url }}" + -SbomUrl "${{ steps.attest-sbom.outputs.attestation-url }}" - name: Upload SBOM and attestations uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -440,10 +401,11 @@ jobs: retention-days: 30 if-no-files-found: error - # The only job that can publish. It compiles nothing and never checks out the repository. + # The only job that can publish. It restores nothing and compiles nothing; its checkout is a + # scripts-only sparse one so the publish gate itself is version-controlled and reviewable. publish: name: Publish to NuGet.org - needs: [pack, sbom] + needs: [pack, sbom, dependency-scan] runs-on: windows-latest timeout-minutes: 15 environment: nuget-org-publish @@ -452,6 +414,13 @@ jobs: id-token: write steps: + - name: Checkout release scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: .github/scripts + sparse-checkout-cone-mode: false + - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -461,40 +430,41 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: nupkg-signed - path: nupkg + path: artifacts - name: Verify the package that was packed, signed, and attested shell: pwsh - env: - PACK_NUPKG_SHA256: ${{ needs.pack.outputs.nupkg-sha256 }} - ATTESTED_NUPKG_SHA256: ${{ needs.sbom.outputs.attested-sha256 }} - run: | - $package = "${{ github.workspace }}\nupkg\Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" - $digest = (Get-FileHash -LiteralPath $package -Algorithm SHA256).Hash.ToLowerInvariant() - - foreach ($expected in @($env:PACK_NUPKG_SHA256, $env:ATTESTED_NUPKG_SHA256)) { - if ($digest -ne $expected) { - throw "Refusing to publish: expected digest $expected but found $digest." - } - } - - Write-Host "Publishing package with digest $digest." + run: > + .github/scripts/Get-PackageDigest.ps1 + -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -ExpectedSha256 "${{ needs.pack.outputs.nupkg-sha256 }}","${{ needs.sbom.outputs.attested-sha256 }}" + -FailureMessage 'Refusing to publish.' - name: Validate NuGet package signature - run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --verbosity quiet + run: dotnet nuget verify "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" --verbosity quiet - name: NuGet login (OIDC Trusted Publishing) - uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 + uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 id: nuget-login with: user: ${{ secrets.INFRAGISTICS_NUGET_ORG_USER }} + # Refuses to publish over an existing version instead of using --skip-duplicate: a full rerun + # produces newly signed bytes, so skipping the duplicate would attach this run's SBOM, + # attestations and checksum to a release whose published package is a different build. - name: Publish to NuGet.org - run: dotnet nuget push "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${env:VERSION}.nupkg" --api-key ${{ steps.nuget-login.outputs.NUGET_API_KEY }} --source "https://api.nuget.org/v3/index.json" --skip-duplicate + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: > + .github/scripts/Publish-NuGetPackage.ps1 + -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -PackageId "${{ env.PACKAGE_ID }}" + -Version "${{ env.VERSION }}" attach-to-release: name: Attach release evidence - needs: [pack, sbom, publish] + needs: [pack, sbom, dependency-scan, publish] runs-on: ubuntu-latest timeout-minutes: 10 permissions: @@ -505,7 +475,7 @@ jobs: uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: nupkg-signed - path: nupkg + path: artifacts - name: Download SBOM and attestations uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -513,21 +483,28 @@ jobs: name: sbom path: sbom + - name: Download dependency scan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: dependency-scan + path: evidence/dependency-scan + - name: Attach evidence to the release env: GH_TOKEN: ${{ github.token }} TAG: ${{ github.ref_name }} - PACKAGE_ID: Infragistics.QueryBuilder.Executor run: | set -euo pipefail (cd sbom/spdx-2.2/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-2.2.zip" .) (cd sbom/spdx-3.0/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-3.0.zip" .) + (cd evidence/dependency-scan && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.dependency-scan.zip" .) gh release upload "$TAG" --clobber -R "${{ github.repository }}" \ - "nupkg/${PACKAGE_ID}.${TAG}.nupkg" \ - "nupkg/${PACKAGE_ID}.${TAG}.nupkg.sha256" \ + "artifacts/${PACKAGE_ID}.${TAG}.nupkg" \ + "artifacts/${PACKAGE_ID}.${TAG}.nupkg.sha256" \ "${PACKAGE_ID}.${TAG}.spdx-2.2.zip" \ "${PACKAGE_ID}.${TAG}.spdx-3.0.zip" \ + "${PACKAGE_ID}.${TAG}.dependency-scan.zip" \ "sbom/attestations/provenance.sigstore.json" \ "sbom/attestations/sbom.sigstore.json" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d7d00e..c749144 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,26 +13,59 @@ on: workflow_dispatch: jobs: + # Blocks a pull request that would introduce a High or Critical advisory. Pushes to master skip it — + # the action needs the two-commit range a pull request gives it. Advisories already on master are + # reported by the release workflow's dependency-scan job instead. + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + permissions: + contents: read + # comment-summary-in-pr needs this. A fork PR gets a read-only token regardless, so there + # the comment is skipped with a warning and the finding is left to the job summary. + pull-requests: write + + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Review dependency changes + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 + with: + fail-on-severity: high + comment-summary-in-pr: on-failure + build: + name: Build & Validate runs-on: windows-latest steps: - name: Checkout code - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # 9.x builds both target frameworks; the net8.0 runtime is what lets tests targeting it run. - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: - dotnet-version: 9.x + dotnet-version: | + 8.0.x + 9.0.x + + # Folder mode formats files directly without loading MSBuild projects — full `dotnet format` + # corrupts sources on multi-targeted projects by inserting per-TFM conflict markers + # (dotnet/format#1634). + - name: Verify .NET code formatting + run: dotnet format whitespace . --folder --verify-no-changes - name: Restore dependencies run: dotnet restore Infragistics.QueryBuilder.Executor.sln - name: Build solution - run: dotnet build Infragistics.QueryBuilder.Executor.sln --configuration Release --no-restore + run: dotnet build Infragistics.QueryBuilder.Executor.sln --configuration Release --no-restore -p:ContinuousIntegrationBuild=true - name: Pack solution - NuGet - run: dotnet pack Infragistics.QueryBuilder.Executor.sln --configuration Release --no-build + run: dotnet pack Infragistics.QueryBuilder.Executor.sln --configuration Release --no-build - name: Run tests run: dotnet test Infragistics.QueryBuilder.Executor.sln --configuration Release --no-build --verbosity normal diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index aa8e7a0..e0d7f46 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -1,8 +1,9 @@ name: Generate SBOM # Dry run for the SBOM that build-and-publish.yml produces at release time, so flag changes can be -# reviewed on a PR. The package packed here is unsigned, so nothing is attested or published: -# provenance and SBOM attestations must bind to the signed package digest. +# reviewed on a PR. It runs the same scripts the release does. The package packed here is unsigned, +# so nothing is attested or published: provenance and SBOM attestations must bind to the signed +# package digest. on: pull_request: types: [labeled] @@ -17,7 +18,10 @@ concurrency: env: BUILD_CONFIGURATION: Release DOTNET_VERSION: '9.x' + PACKAGE_ID: Infragistics.QueryBuilder.Executor + PROJECT_PATH: Infragistics.QueryBuilder.Executor.csproj PACKAGE_VERSION: 0.0.0-pr.${{ github.event.pull_request.number }} + SBOM_LICENSE_TIMEOUT_SECONDS: '180' jobs: sbom: @@ -27,7 +31,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -38,64 +42,32 @@ jobs: - name: Pack NuGet package run: > - dotnet pack Infragistics.QueryBuilder.Executor.csproj + dotnet pack ${{ env.PROJECT_PATH }} --configuration ${{ env.BUILD_CONFIGURATION }} -p:Version=${{ env.PACKAGE_VERSION }} - -o ./artifacts + -o "${{ github.workspace }}/artifacts" # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore' used by build-and-publish.yml - name: Restore sbom-tool (pinned) run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json - # sbom-tool fails unless the -m directory already exists. - - name: Create SBOM output directories - run: mkdir -p sbom/spdx-2.2 sbom/spdx-3.0 - - # -b: the packed nupkg gets listed with its hash in the SBOM's files section - # -bc: dependency detection scans the repo root (csproj) - # -li/-pm: resolve license and supplier metadata for the detected packages - - name: Generate SBOM (SPDX 2.2) - working-directory: .config/sbom-tool + - name: Generate SBOMs + shell: pwsh run: > - dotnet tool run sbom-tool -- generate - -b ${{ github.workspace }}/artifacts - -bc ${{ github.workspace }} - -m ${{ github.workspace }}/sbom/spdx-2.2 - -pn Infragistics.QueryBuilder.Executor - -pv ${{ env.PACKAGE_VERSION }} - -ps "Infragistics Inc." - -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor - -mi SPDX:2.2 - -li true - -lto 60 - -pm true - -V Information + .github/scripts/New-Sbom.ps1 + -PackageId "${{ env.PACKAGE_ID }}" + -PackageVersion "${{ env.PACKAGE_VERSION }}" + -BuildDropPath "${{ github.workspace }}/artifacts" + -BuildComponentPath "${{ github.workspace }}" + -OutputRoot "${{ github.workspace }}/sbom" + -LicenseTimeoutSeconds ${{ env.SBOM_LICENSE_TIMEOUT_SECONDS }} - - name: Generate SBOM (SPDX 3.0) - working-directory: .config/sbom-tool + - name: Verify SBOM output + shell: pwsh run: > - dotnet tool run sbom-tool -- generate - -b ${{ github.workspace }}/artifacts - -bc ${{ github.workspace }} - -m ${{ github.workspace }}/sbom/spdx-3.0 - -pn Infragistics.QueryBuilder.Executor - -pv ${{ env.PACKAGE_VERSION }} - -ps "Infragistics Inc." - -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor - -mi SPDX:3.0 - -li true - -lto 60 - -pm true - -V Information - - - name: Verify SBOM - run: | - set -euo pipefail - test -s sbom/spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json - test -s sbom/spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json.sha256 - test -s sbom/spdx-3.0/_manifest/spdx_3.0/manifest.spdx.json - test -s sbom/spdx-3.0/_manifest/spdx_3.0/manifest.spdx.json.sha256 - echo "SBOM generated successfully." + .github/scripts/Assert-Sbom.ps1 + -OutputRoot "${{ github.workspace }}/sbom" + -ExpectedFileName "${{ env.PACKAGE_ID }}.${{ env.PACKAGE_VERSION }}.nupkg" - name: Upload SBOM files uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From 03789dba9455762d2f5d53216c441c0d462f46a0 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 12:47:28 +0300 Subject: [PATCH 17/22] metadata tweaks to the csproj - for the resulting NuGet package --- Infragistics.QueryBuilder.Executor.csproj | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/Infragistics.QueryBuilder.Executor.csproj b/Infragistics.QueryBuilder.Executor.csproj index d5bae58..7e942e6 100644 --- a/Infragistics.QueryBuilder.Executor.csproj +++ b/Infragistics.QueryBuilder.Executor.csproj @@ -8,9 +8,9 @@ Infragistics Inc. Infragistics Inc. Infragistics Inc. - A .NET 9 library for dynamic, strongly-typed query building and execution over Entity Framework Core data sources. Supports advanced filtering, projection, and SQL generation. - 1.0.0.0 - We want to generate the version attributes at runtime and we need this flag enabled for that<--> + A .NET library for dynamic, strongly-typed query building and execution over Entity Framework Core data sources. Supports advanced filtering, projection, and SQL generation. + 1.0.0 + true true false @@ -23,17 +23,23 @@ https://github.com/IgniteUI/Infragistics.QueryBuilder.Executor Infragistics;App Builder;Ignite UI;Filtering;Query;Query Builder;Models IgniteUI.png + true + + true + true + + + - - True - - + + From 4666b829f353e40baf2520d31766d392b71c3f14 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 15:34:46 +0300 Subject: [PATCH 18/22] Address local code review findings --- .github/scripts/Assert-NuGetSignature.ps1 | 46 +++++++++ .github/scripts/Assert-ReleaseVersion.ps1 | 25 +++++ .github/scripts/Assert-Sbom.ps1 | 115 ++++++++++++++++++---- .github/scripts/New-Sbom.ps1 | 3 +- .github/workflows/build-and-publish.yml | 54 +++++----- .github/workflows/sbom.yml | 2 +- 6 files changed, 201 insertions(+), 44 deletions(-) create mode 100644 .github/scripts/Assert-NuGetSignature.ps1 create mode 100644 .github/scripts/Assert-ReleaseVersion.ps1 diff --git a/.github/scripts/Assert-NuGetSignature.ps1 b/.github/scripts/Assert-NuGetSignature.ps1 new file mode 100644 index 0000000..99d90a0 --- /dev/null +++ b/.github/scripts/Assert-NuGetSignature.ps1 @@ -0,0 +1,46 @@ +<# +.SYNOPSIS + Verifies that a NuGet package is signed by an approved certificate. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$PackagePath, + + [Parameter(Mandatory)] + [string]$ExpectedCertificateSha256Path +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $false + +if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { + throw "NuGet package not found: $PackagePath" +} + +if (-not (Test-Path -LiteralPath $ExpectedCertificateSha256Path -PathType Leaf)) { + throw "Pinned certificate fingerprint file not found: $ExpectedCertificateSha256Path" +} + +$allowedFingerprints = @( + Get-Content -LiteralPath $ExpectedCertificateSha256Path | + ForEach-Object { $_.Trim().ToUpperInvariant() } | + Where-Object { $_ -and -not $_.StartsWith('#') } +) + +if ($allowedFingerprints.Count -eq 0 -or @($allowedFingerprints | Where-Object { $_ -notmatch '^[0-9A-F]{64}$' }).Count -gt 0) { + throw "$ExpectedCertificateSha256Path must contain at least one valid SHA-256 certificate fingerprint." +} + +$verifyArguments = @('nuget', 'verify', $PackagePath, '--all') +foreach ($fingerprint in $allowedFingerprints) { + $verifyArguments += @('--certificate-fingerprint', $fingerprint) +} +$verifyArguments += @('--verbosity', 'quiet') + +& dotnet @verifyArguments +if ($LASTEXITCODE -ne 0) { + throw "NuGet signature validation failed or the signer is not approved by '$ExpectedCertificateSha256Path'." +} + +Write-Host "NuGet package signature matches an approved certificate." \ No newline at end of file diff --git a/.github/scripts/Assert-ReleaseVersion.ps1 b/.github/scripts/Assert-ReleaseVersion.ps1 new file mode 100644 index 0000000..5bb2945 --- /dev/null +++ b/.github/scripts/Assert-ReleaseVersion.ps1 @@ -0,0 +1,25 @@ +<# +.SYNOPSIS + Validates that a release tag is a package version supported by the release workflow. + +.DESCRIPTION + Release tag names are untrusted input. This script accepts the repository's existing bare SemVer + convention and rejects values that could be interpreted as PowerShell when used by later jobs. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$Version +) + +$ErrorActionPreference = 'Stop' + +$coreIdentifier = '(?:0|[1-9][0-9]*)' +$prereleaseIdentifier = '(?:0|[1-9][0-9]*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)' +$supportedVersionPattern = "^$coreIdentifier\.$coreIdentifier\.$coreIdentifier(?:-$prereleaseIdentifier(?:\.$prereleaseIdentifier)*)?$" + +if ($Version -notmatch $supportedVersionPattern) { + throw "Release tag '$Version' must be a bare SemVer package version such as '1.2.3' or '1.2.3-prerelease.4'. A 'v' prefix and build metadata are not supported." +} + +Write-Host "Validated release version $Version." \ No newline at end of file diff --git a/.github/scripts/Assert-Sbom.ps1 b/.github/scripts/Assert-Sbom.ps1 index c917e00..862c94c 100644 --- a/.github/scripts/Assert-Sbom.ps1 +++ b/.github/scripts/Assert-Sbom.ps1 @@ -1,35 +1,118 @@ <# .SYNOPSIS - Asserts the generated SPDX 2.2 and 3.0 manifests exist, are non-empty, and describe the shipped package. + Verifies the generated SPDX 2.2 and 3.0 manifests and their relationship to the shipped package. .DESCRIPTION - sbom-tool exits 0 for a document that lists nothing useful, so the release gate is this check rather - than the tool's exit code. + Requires parseable, structurally valid documents, verifies each manifest against its SHA-256 sidecar, + and proves that the SPDX 2.2 file entry describes the exact package bytes being released. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$OutputRoot, - # The nupkg that must appear in the SPDX 2.2 files section; omit to only check the documents exist. - [string]$ExpectedFileName + [Parameter(Mandatory)] + [string]$ExpectedPackagePath ) $ErrorActionPreference = 'Stop' $manifests = @( - (Join-Path $OutputRoot 'spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json'), - (Join-Path $OutputRoot 'spdx-3.0/_manifest/spdx_3.0/manifest.spdx.json') + [pscustomobject]@{ + Name = 'SPDX 2.2' + Path = Join-Path $OutputRoot 'spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json' + }, + [pscustomobject]@{ + Name = 'SPDX 3.0' + Path = Join-Path $OutputRoot 'spdx-3.0/_manifest/spdx_3.0/manifest.spdx.json' + } ) $problems = @() +$documents = @{} foreach ($manifest in $manifests) { - foreach ($file in @($manifest, "$manifest.sha256")) { - if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { - $problems += "SBOM file missing: $file" + if (-not (Test-Path -LiteralPath $manifest.Path -PathType Leaf)) { + $problems += "$($manifest.Name) document missing: $($manifest.Path)" + continue + } + + if ((Get-Item -LiteralPath $manifest.Path).Length -eq 0) { + $problems += "$($manifest.Name) document is empty: $($manifest.Path)" + continue + } + + try { + $documents[$manifest.Name] = Get-Content -LiteralPath $manifest.Path -Raw | ConvertFrom-Json -ErrorAction Stop + } + catch { + $problems += "$($manifest.Name) document is not valid JSON: $($_.Exception.Message)" + } + + $checksumPath = "$($manifest.Path).sha256" + if (-not (Test-Path -LiteralPath $checksumPath -PathType Leaf)) { + $problems += "$($manifest.Name) checksum missing: $checksumPath" + continue + } + + $recordedChecksum = (Get-Content -LiteralPath $checksumPath -Raw).Trim().ToLowerInvariant() + if ($recordedChecksum -notmatch '^[0-9a-f]{64}$') { + $problems += "$($manifest.Name) checksum sidecar does not contain one SHA-256 digest: $checksumPath" + continue + } + + $actualChecksum = (Get-FileHash -LiteralPath $manifest.Path -Algorithm SHA256).Hash.ToLowerInvariant() + if ($recordedChecksum -ne $actualChecksum) { + $problems += "$($manifest.Name) checksum is $recordedChecksum, but the document hash is $actualChecksum." + } +} + +if ($documents.ContainsKey('SPDX 2.2')) { + $spdx22 = $documents['SPDX 2.2'] + if ($spdx22.spdxVersion -ne 'SPDX-2.2') { + $problems += "SPDX 2.2 document declares version '$($spdx22.spdxVersion)'." + } + if (@($spdx22.packages).Count -eq 0) { + $problems += 'SPDX 2.2 document contains no packages.' + } + if (@($spdx22.files).Count -eq 0) { + $problems += 'SPDX 2.2 document contains no files.' + } +} + +if ($documents.ContainsKey('SPDX 3.0')) { + $spdx30 = $documents['SPDX 3.0'] + if (@($spdx30.'@context').Count -eq 0) { + $problems += 'SPDX 3.0 document contains no @context.' + } + if (@($spdx30.'@graph').Count -eq 0) { + $problems += 'SPDX 3.0 document contains no @graph entries.' + } +} + +if (-not (Test-Path -LiteralPath $ExpectedPackagePath -PathType Leaf)) { + $problems += "Expected NuGet package missing: $ExpectedPackagePath" +} +elseif ($documents.ContainsKey('SPDX 2.2')) { + $expectedFileName = [System.IO.Path]::GetFileName($ExpectedPackagePath) + $expectedPackageHash = (Get-FileHash -LiteralPath $ExpectedPackagePath -Algorithm SHA256).Hash.ToLowerInvariant() + $packageFiles = @( + $documents['SPDX 2.2'].files | Where-Object { + $_.fileName -and [System.IO.Path]::GetFileName([string]$_.fileName) -eq $expectedFileName } - elseif ((Get-Item -LiteralPath $file).Length -eq 0) { - $problems += "SBOM file is empty: $file" + ) + + if ($packageFiles.Count -ne 1) { + $problems += "SPDX 2.2 document contains $($packageFiles.Count) file entries for $expectedFileName; expected exactly one." + } + else { + $recordedPackageHashes = @( + $packageFiles[0].checksums | + Where-Object { $_.algorithm -eq 'SHA256' } | + ForEach-Object { ([string]$_.checksumValue).ToLowerInvariant() } + ) + + if ($expectedPackageHash -notin $recordedPackageHashes) { + $problems += "SPDX 2.2 records SHA-256 '$($recordedPackageHashes -join ', ')' for $expectedFileName, but the package hash is $expectedPackageHash." } } } @@ -38,10 +121,4 @@ if ($problems.Count -gt 0) { throw "SBOM validation failed:`n- $($problems -join "`n- ")" } -$spdx = Get-Content -LiteralPath $manifests[0] -Raw | ConvertFrom-Json - -if ($ExpectedFileName -and -not ($spdx.files | Where-Object { $_.fileName -like "*$ExpectedFileName" })) { - throw "The SPDX 2.2 document does not reference $ExpectedFileName." -} - -Write-Host "SBOM covers $($spdx.packages.Count) packages and $($spdx.files.Count) files." +Write-Host "SBOM covers $(@($documents['SPDX 2.2'].packages).Count) packages and $(@($documents['SPDX 2.2'].files).Count) files, including the verified NuGet package." diff --git a/.github/scripts/New-Sbom.ps1 b/.github/scripts/New-Sbom.ps1 index 62bdcb3..c8563b4 100644 --- a/.github/scripts/New-Sbom.ps1 +++ b/.github/scripts/New-Sbom.ps1 @@ -69,8 +69,7 @@ try { -mi $format.Version ` -li $ResolveLicenses.ToString().ToLowerInvariant() ` -lto $LicenseTimeoutSeconds ` - -pm true ` - -V Information + -pm true } } finally { diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index bb57650..f766bfd 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -37,6 +37,10 @@ jobs: with: persist-credentials: false + - name: Validate release tag + shell: pwsh + run: .github/scripts/Assert-ReleaseVersion.ps1 -Version "$env:VERSION" + - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 with: @@ -63,16 +67,11 @@ jobs: dotnet build ${{ env.PROJECT_PATH }} --configuration ${{ env.BUILD_CONFIGURATION }} --no-restore - -p:Version=${{ env.VERSION }} + "-p:Version=$env:VERSION" -p:ContinuousIntegrationBuild=true -p:SignAssembly=true -p:AssemblyOriginatorKeyFile="${{ runner.temp }}\IG.StrongName.snk" - - name: Delete strong-name key - if: always() - shell: pwsh - run: Remove-Item "${{ runner.temp }}\IG.StrongName.snk" -Force -ErrorAction SilentlyContinue - - name: Upload build output uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -184,12 +183,13 @@ jobs: # Repository url and commit are passed explicitly so the nuspec never carries a commit # without a url (or vice versa) depending on what the checkout leaves in .git. - name: Pack NuGet package + shell: pwsh run: > dotnet pack ${{ env.PROJECT_PATH }} --configuration ${{ env.BUILD_CONFIGURATION }} --no-build --no-restore - -p:PackageVersion=${{ env.VERSION }} + "-p:PackageVersion=$env:VERSION" -p:RepositoryUrl=${{ env.REPOSITORY_URL }} -p:RepositoryType=git -p:RepositoryCommit=${{ github.sha }} @@ -199,7 +199,7 @@ jobs: shell: pwsh run: > .github/scripts/Assert-PackageStrongName.ps1 - -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" -ExpectedPublicKeyPath "${{ env.EXPECTED_PUBLIC_KEY_PATH }}" -WorkingDirectory "${{ runner.temp }}/strong-name-validation" @@ -207,11 +207,11 @@ jobs: shell: pwsh run: > .github/scripts/Assert-NuspecRepository.ps1 - -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" -ExpectedRepositoryUrl "${{ env.REPOSITORY_URL }}" -ExpectedCommit "${{ github.sha }}" -ExpectedPackageId "${{ env.PACKAGE_ID }}" - -ExpectedVersion "${{ env.VERSION }}" + -ExpectedVersion "$env:VERSION" - name: Restore .NET local tools run: dotnet tool restore @@ -235,7 +235,11 @@ jobs: --verbosity Warning - name: Validate NuGet package signature - run: dotnet nuget verify "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" --verbosity quiet + shell: pwsh + run: > + .github/scripts/Assert-NuGetSignature.ps1 + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" + -ExpectedCertificateSha256Path "${{ env.EXPECTED_CERT_SHA256_PATH }}" # This digest is the identity every downstream job re-checks before acting on the package. - name: Record package digest @@ -243,7 +247,7 @@ jobs: shell: pwsh run: > .github/scripts/Get-PackageDigest.ps1 - -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" -WriteChecksumFile -GitHubOutputName 'nupkg-sha256' -SummaryTitle 'Signed package digest' @@ -284,7 +288,7 @@ jobs: -ProjectPath "${{ env.PROJECT_PATH }}" -OutputDirectory "${{ github.workspace }}/artifacts/dependency-scan" -PackageId "${{ env.PACKAGE_ID }}" - -Version "${{ env.VERSION }}" + -Version "$env:VERSION" - name: Upload dependency scan uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -327,7 +331,7 @@ jobs: shell: pwsh run: > .github/scripts/Get-PackageDigest.ps1 - -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" -ExpectedSha256 "${{ needs.pack.outputs.nupkg-sha256 }}" - name: Restore .NET dependencies @@ -342,7 +346,7 @@ jobs: run: > .github/scripts/New-Sbom.ps1 -PackageId "${{ env.PACKAGE_ID }}" - -PackageVersion "${{ env.VERSION }}" + -PackageVersion "$env:VERSION" -BuildDropPath "${{ github.workspace }}/artifacts" -BuildComponentPath "${{ github.workspace }}" -OutputRoot "${{ github.workspace }}/sbom" @@ -353,7 +357,7 @@ jobs: run: > .github/scripts/Assert-Sbom.ps1 -OutputRoot "${{ github.workspace }}/sbom" - -ExpectedFileName "${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -ExpectedPackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" # An attestation binds to a digest rather than a path, so the bytes are re-hashed here and that # value is what gets attested, re-checked before publishing, and reported on the release. @@ -362,7 +366,7 @@ jobs: shell: pwsh run: > .github/scripts/Get-PackageDigest.ps1 - -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" -ExpectedSha256 "${{ needs.pack.outputs.nupkg-sha256 }}" -FailureMessage 'Package changed before attestation.' -GitHubOutputName 'nupkg-sha256' @@ -418,7 +422,9 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - sparse-checkout: .github/scripts + sparse-checkout: | + .github/scripts + eng/IG.authenticode-certificates.sha256 sparse-checkout-cone-mode: false - name: Setup .NET @@ -436,12 +442,16 @@ jobs: shell: pwsh run: > .github/scripts/Get-PackageDigest.ps1 - -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" -ExpectedSha256 "${{ needs.pack.outputs.nupkg-sha256 }}","${{ needs.sbom.outputs.attested-sha256 }}" -FailureMessage 'Refusing to publish.' - name: Validate NuGet package signature - run: dotnet nuget verify "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" --verbosity quiet + shell: pwsh + run: > + .github/scripts/Assert-NuGetSignature.ps1 + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" + -ExpectedCertificateSha256Path "${{ env.EXPECTED_CERT_SHA256_PATH }}" - name: NuGet login (OIDC Trusted Publishing) uses: NuGet/login@8d196754b4036150537f80ac539e15c2f1028841 # v1 @@ -458,9 +468,9 @@ jobs: NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} run: > .github/scripts/Publish-NuGetPackage.ps1 - -PackagePath "${{ github.workspace }}/artifacts/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg" + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" -PackageId "${{ env.PACKAGE_ID }}" - -Version "${{ env.VERSION }}" + -Version "$env:VERSION" attach-to-release: name: Attach release evidence diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index e0d7f46..cc076d1 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -67,7 +67,7 @@ jobs: run: > .github/scripts/Assert-Sbom.ps1 -OutputRoot "${{ github.workspace }}/sbom" - -ExpectedFileName "${{ env.PACKAGE_ID }}.${{ env.PACKAGE_VERSION }}.nupkg" + -ExpectedPackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:PACKAGE_VERSION}.nupkg" - name: Upload SBOM files uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 From 7bfa502e69f7113c7c87640131bc022d5d6c1468 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 16:57:22 +0300 Subject: [PATCH 19/22] Address the package cache issue that decided to pop up today --- .github/workflows/build-and-publish.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index f766bfd..048109a 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -180,6 +180,11 @@ jobs: path: . digest-mismatch: error + # Pack re-resolves package assets whenever the downloaded obj cache is treated as stale, and + # then needs the packages on this runner. Restoring is not a rebuild: the signed DLLs stand. + - name: Restore .NET dependencies + run: dotnet restore ${{ env.PROJECT_PATH }} + # Repository url and commit are passed explicitly so the nuspec never carries a commit # without a url (or vice versa) depending on what the checkout leaves in .git. - name: Pack NuGet package From 37b7694debbdb70795ccb08f7832cfa481ff017d Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Tue, 1 Sep 2026 17:28:52 +0300 Subject: [PATCH 20/22] Reword where the NuGet API key is sourced from - to avoid a potential security concern Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/scripts/Publish-NuGetPackage.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/scripts/Publish-NuGetPackage.ps1 b/.github/scripts/Publish-NuGetPackage.ps1 index 5b0be29..c526a28 100644 --- a/.github/scripts/Publish-NuGetPackage.ps1 +++ b/.github/scripts/Publish-NuGetPackage.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION Deliberately not --skip-duplicate: a rerun produces newly signed bytes, so skipping the duplicate would attach this run's SBOM, attestations and checksum to a release whose published package is a - different build. The API key is read from NUGET_API_KEY so it never appears in a command line. + different build. The API key is sourced from NUGET_API_KEY rather than embedded in the workflow command. #> [CmdletBinding()] param( From 882485787be2ad77713ff963d03f6e41db41a877 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Wed, 2 Sep 2026 16:25:51 +0300 Subject: [PATCH 21/22] Refactoring and introduce CycloneDX SBOM --- .config/dotnet-tools.json | 14 ++ .config/sbom-tool/dotnet-tools.json | 13 -- ...name.ps1 => Assert-AssemblyStrongName.ps1} | 0 .github/scripts/Assert-NuspecRepository.ps1 | 130 ------------------ .github/scripts/Assert-PackageStrongName.ps1 | 2 +- .github/scripts/Assert-Sbom.ps1 | 51 ++++++- .github/scripts/Copy-AttestationBundles.ps1 | 18 ++- .github/scripts/New-CycloneDxSbom.ps1 | 105 ++++++++++++++ .github/scripts/New-Sbom.ps1 | 128 +++++++++++++---- .github/workflows/build-and-publish.yml | 67 +++++---- .github/workflows/sbom.yml | 23 +++- 11 files changed, 339 insertions(+), 212 deletions(-) delete mode 100644 .config/sbom-tool/dotnet-tools.json rename .github/scripts/{verify-strong-name.ps1 => Assert-AssemblyStrongName.ps1} (100%) delete mode 100644 .github/scripts/Assert-NuspecRepository.ps1 create mode 100644 .github/scripts/New-CycloneDxSbom.ps1 diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 805555f..082d426 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -8,6 +8,20 @@ "sign" ], "rollForward": false + }, + "microsoft.sbom.dotnettool": { + "version": "4.1.5", + "commands": [ + "sbom-tool" + ], + "rollForward": true + }, + "cyclonedx": { + "version": "6.2.0", + "commands": [ + "dotnet-CycloneDX" + ], + "rollForward": true } } } diff --git a/.config/sbom-tool/dotnet-tools.json b/.config/sbom-tool/dotnet-tools.json deleted file mode 100644 index eabcff8..0000000 --- a/.config/sbom-tool/dotnet-tools.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": 1, - "isRoot": true, - "tools": { - "microsoft.sbom.dotnettool": { - "version": "4.1.5", - "commands": [ - "sbom-tool" - ], - "rollForward": true - } - } -} diff --git a/.github/scripts/verify-strong-name.ps1 b/.github/scripts/Assert-AssemblyStrongName.ps1 similarity index 100% rename from .github/scripts/verify-strong-name.ps1 rename to .github/scripts/Assert-AssemblyStrongName.ps1 diff --git a/.github/scripts/Assert-NuspecRepository.ps1 b/.github/scripts/Assert-NuspecRepository.ps1 deleted file mode 100644 index d63606e..0000000 --- a/.github/scripts/Assert-NuspecRepository.ps1 +++ /dev/null @@ -1,130 +0,0 @@ -<# -.SYNOPSIS - Asserts a packed NuGet package carries the provenance metadata consumers rely on. - -.DESCRIPTION - The nuspec is generated from MSBuild properties at pack time, so an unset one yields a package that - restores fine but cannot be traced back to source. This fails the release instead of shipping it. -#> -[CmdletBinding()] -param( - [Parameter(Mandatory)] - [string]$PackagePath, - - [Parameter(Mandatory)] - [string]$ExpectedRepositoryUrl, - - [Parameter(Mandatory)] - [string]$ExpectedCommit, - - [string]$ExpectedPackageId, - - [string]$ExpectedVersion -) - -$ErrorActionPreference = 'Stop' - -if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) { - throw "NuGet package not found: $PackagePath" -} - -if ($ExpectedCommit -notmatch '^[0-9a-fA-F]{40}$') { - throw "ExpectedCommit must be a full 40-character git SHA, but was '$ExpectedCommit'." -} - -Add-Type -AssemblyName System.IO.Compression.FileSystem - -$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath).ProviderPath) -try { - $entry = $archive.Entries | - Where-Object { $_.FullName -notlike '*/*' -and $_.FullName -like '*.nuspec' } | - Select-Object -First 1 - - if ($null -eq $entry) { - throw "No .nuspec found at the root of $PackagePath." - } - - $reader = New-Object System.IO.StreamReader($entry.Open()) - try { - $nuspecXml = $reader.ReadToEnd() - } - finally { - $reader.Dispose() - } -} -finally { - $archive.Dispose() -} - -$document = New-Object System.Xml.XmlDocument -$document.PreserveWhitespace = $false -$document.LoadXml($nuspecXml) - -# The nuspec default namespace changes with the schema version, so match on local names only. -$metadata = $document.SelectSingleNode('/*[local-name()="package"]/*[local-name()="metadata"]') -if ($null -eq $metadata) { - throw "The nuspec in $PackagePath has no element." -} - -function Get-MetadataValue([string]$Name) { - $node = $metadata.SelectSingleNode("*[local-name()=`"$Name`"]") - if ($null -eq $node) { return $null } - return $node.InnerText.Trim() -} - -$problems = @() - -function Assert-Value([string]$Label, [string]$Actual, [string]$Expected) { - if ([string]::IsNullOrWhiteSpace($Actual)) { - $script:problems += "$Label is missing from the nuspec." - } - elseif ($Expected -and $Actual -ne $Expected) { - $script:problems += "$Label is '$Actual', expected '$Expected'." - } -} - -$repository = $metadata.SelectSingleNode('*[local-name()="repository"]') -if ($null -eq $repository) { - $problems += ' is missing from the nuspec.' -} -else { - Assert-Value 'repository/@type' $repository.GetAttribute('type') 'git' - Assert-Value 'repository/@url' $repository.GetAttribute('url') $ExpectedRepositoryUrl - Assert-Value 'repository/@commit' $repository.GetAttribute('commit') $ExpectedCommit -} - -Assert-Value 'authors' (Get-MetadataValue 'authors') $null -Assert-Value 'projectUrl' (Get-MetadataValue 'projectUrl') $null -Assert-Value 'description' (Get-MetadataValue 'description') $null -Assert-Value 'copyright' (Get-MetadataValue 'copyright') $null - -if ($ExpectedPackageId) { - Assert-Value 'id' (Get-MetadataValue 'id') $ExpectedPackageId -} - -if ($ExpectedVersion) { - Assert-Value 'version' (Get-MetadataValue 'version') $ExpectedVersion -} - -$license = $metadata.SelectSingleNode('*[local-name()="license"]') -if ($null -eq $license) { - $problems += ' is missing from the nuspec.' -} -elseif ($license.GetAttribute('type') -ne 'expression') { - $problems += "license/@type is '$($license.GetAttribute('type'))', expected 'expression'." -} - -# 'authors' defaults to the assembly name when is unset, which is not an author. -$authors = Get-MetadataValue 'authors' -if ($authors -and $ExpectedPackageId -and $authors -eq $ExpectedPackageId) { - $problems += "authors is '$authors', which is the package id rather than a real author. Set in the project file." -} - -if ($problems.Count -gt 0) { - throw "Package provenance metadata validation failed for $([System.IO.Path]::GetFileName($PackagePath)):`n- $($problems -join "`n- ")" -} - -Write-Host "Verified nuspec provenance for $([System.IO.Path]::GetFileName($PackagePath)):" -Write-Host " repository url : $($repository.GetAttribute('url'))" -Write-Host " repository commit : $($repository.GetAttribute('commit'))" -Write-Host " authors : $authors" diff --git a/.github/scripts/Assert-PackageStrongName.ps1 b/.github/scripts/Assert-PackageStrongName.ps1 index f1d47d0..f0890ed 100644 --- a/.github/scripts/Assert-PackageStrongName.ps1 +++ b/.github/scripts/Assert-PackageStrongName.ps1 @@ -32,7 +32,7 @@ try { Copy-Item -LiteralPath $PackagePath -Destination $archivePath -Force Expand-Archive -LiteralPath $archivePath -DestinationPath $extractPath -Force - & (Join-Path $PSScriptRoot 'verify-strong-name.ps1') -Path $extractPath -ExpectedPublicKeyPath $ExpectedPublicKeyPath + & (Join-Path $PSScriptRoot 'Assert-AssemblyStrongName.ps1') -Path $extractPath -ExpectedPublicKeyPath $ExpectedPublicKeyPath } finally { Remove-Item -LiteralPath $WorkingDirectory -Recurse -Force -ErrorAction SilentlyContinue diff --git a/.github/scripts/Assert-Sbom.ps1 b/.github/scripts/Assert-Sbom.ps1 index 862c94c..fec3784 100644 --- a/.github/scripts/Assert-Sbom.ps1 +++ b/.github/scripts/Assert-Sbom.ps1 @@ -5,6 +5,11 @@ .DESCRIPTION Requires parseable, structurally valid documents, verifies each manifest against its SHA-256 sidecar, and proves that the SPDX 2.2 file entry describes the exact package bytes being released. + + Also guards the two failure modes sbom-tool does not report: a manifest that ended up inside the + component scan root and so describes itself, and a ClearlyDefined outage that silently replaces every + license with NOASSERTION. Missing licenses are reported rather than fatal - NOASSERTION is a valid + SPDX value and the upstream tool offers no way to require otherwise. #> [CmdletBinding()] param( @@ -12,7 +17,11 @@ param( [string]$OutputRoot, [Parameter(Mandatory)] - [string]$ExpectedPackagePath + [string]$ExpectedPackagePath, + + # Below this share of packages carrying a resolved license, the run is annotated rather than failed. + [ValidateRange(0, 1)] + [double]$MinimumLicenseCoverage = 0.8 ) $ErrorActionPreference = 'Stop' @@ -20,11 +29,11 @@ $ErrorActionPreference = 'Stop' $manifests = @( [pscustomobject]@{ Name = 'SPDX 2.2' - Path = Join-Path $OutputRoot 'spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json' + Path = Join-Path $OutputRoot '_manifest/spdx_2.2/manifest.spdx.json' }, [pscustomobject]@{ Name = 'SPDX 3.0' - Path = Join-Path $OutputRoot 'spdx-3.0/_manifest/spdx_3.0/manifest.spdx.json' + Path = Join-Path $OutputRoot '_manifest/spdx_3.0/manifest.spdx.json' } ) @@ -89,6 +98,25 @@ if ($documents.ContainsKey('SPDX 3.0')) { } } +if ($documents.ContainsKey('SPDX 2.2') -and $documents.ContainsKey('SPDX 3.0')) { + $graph = @($documents['SPDX 3.0'].'@graph') + + # Either document describing an SBOM manifest means the output landed inside the scanned tree. + $selfReferences = @( + @($documents['SPDX 2.2'].files | Where-Object { $_.fileName -like '*manifest.spdx.json' }) + + @($graph | Where-Object { $_.type -eq 'software_File' -and $_.name -like '*manifest.spdx.json' }) + ) + if ($selfReferences.Count -gt 0) { + $problems += "The SBOMs describe $($selfReferences.Count) SBOM manifest file(s) as build content. Generate them outside the component scan root." + } + + $packages22 = @($documents['SPDX 2.2'].packages).Count + $packages30 = @($graph | Where-Object { $_.type -eq 'software_Package' }).Count + if ($packages22 -ne $packages30) { + $problems += "SPDX 2.2 records $packages22 packages but SPDX 3.0 records $packages30. The two formats must describe the same build." + } +} + if (-not (Test-Path -LiteralPath $ExpectedPackagePath -PathType Leaf)) { $problems += "Expected NuGet package missing: $ExpectedPackagePath" } @@ -121,4 +149,19 @@ if ($problems.Count -gt 0) { throw "SBOM validation failed:`n- $($problems -join "`n- ")" } -Write-Host "SBOM covers $(@($documents['SPDX 2.2'].packages).Count) packages and $(@($documents['SPDX 2.2'].files).Count) files, including the verified NuGet package." +$packages = @($documents['SPDX 2.2'].packages) +$licensed = @($packages | Where-Object { $_.licenseConcluded -and $_.licenseConcluded -ne 'NOASSERTION' }) +$coverage = if ($packages.Count -gt 0) { $licensed.Count / $packages.Count } else { 0 } + +Write-Host "SBOM covers $($packages.Count) packages and $(@($documents['SPDX 2.2'].files).Count) files, including the verified NuGet package." +Write-Host "License coverage: $($licensed.Count) of $($packages.Count) packages ($([math]::Round($coverage * 100))%)." + +if ($coverage -lt $MinimumLicenseCoverage) { + $unlicensed = @($packages | Where-Object { -not $_.licenseConcluded -or $_.licenseConcluded -eq 'NOASSERTION' } | ForEach-Object { "$($_.name)@$($_.versionInfo)" }) + Write-Warning "Only $([math]::Round($coverage * 100))% of packages carry a resolved license (threshold $([math]::Round($MinimumLicenseCoverage * 100))%). Unresolved: $($unlicensed -join ', ')" +} + +$reciprocal = @($packages | Where-Object { $_.licenseConcluded -match 'GPL|RPL|MPL|EPL|CDDL|OSL|SSPL' }) +if ($reciprocal.Count -gt 0) { + Write-Warning "Reciprocal or copyleft licenses detected: $(($reciprocal | ForEach-Object { "$($_.name)@$($_.versionInfo) ($($_.licenseConcluded))" }) -join '; ')" +} diff --git a/.github/scripts/Copy-AttestationBundles.ps1 b/.github/scripts/Copy-AttestationBundles.ps1 index 1f58898..5209209 100644 --- a/.github/scripts/Copy-AttestationBundles.ps1 +++ b/.github/scripts/Copy-AttestationBundles.ps1 @@ -5,6 +5,9 @@ .DESCRIPTION actions/attest writes each bundle to a temporary path that only the producing job can read, so the bundles are collected into the release artifact while they are still reachable. + + The two SBOM attestations carry different predicate types - https://spdx.dev/Document and + https://cyclonedx.org/bom - so a verifier can ask for either without ambiguity. #> [CmdletBinding()] param( @@ -12,14 +15,19 @@ param( [string]$ProvenanceBundlePath, [Parameter(Mandatory)] - [string]$SbomBundlePath, + [string]$SpdxBundlePath, + + [Parameter(Mandatory)] + [string]$CycloneDxBundlePath, [Parameter(Mandatory)] [string]$Destination, [string]$ProvenanceUrl, - [string]$SbomUrl, + [string]$SpdxUrl, + + [string]$CycloneDxUrl, [string]$SummaryPath = $env:GITHUB_STEP_SUMMARY ) @@ -28,12 +36,14 @@ $ErrorActionPreference = 'Stop' New-Item -ItemType Directory -Path $Destination -Force | Out-Null Copy-Item -LiteralPath $ProvenanceBundlePath -Destination (Join-Path $Destination 'provenance.sigstore.json') -Force -Copy-Item -LiteralPath $SbomBundlePath -Destination (Join-Path $Destination 'sbom.sigstore.json') -Force +Copy-Item -LiteralPath $SpdxBundlePath -Destination (Join-Path $Destination 'sbom-spdx.sigstore.json') -Force +Copy-Item -LiteralPath $CycloneDxBundlePath -Destination (Join-Path $Destination 'sbom-cyclonedx.sigstore.json') -Force if ($SummaryPath) { @( '### Attestations', "- Provenance: $ProvenanceUrl", - "- SBOM: $SbomUrl" + "- SBOM (SPDX 2.2): $SpdxUrl", + "- SBOM (CycloneDX): $CycloneDxUrl" ) | Add-Content -LiteralPath $SummaryPath } diff --git a/.github/scripts/New-CycloneDxSbom.ps1 b/.github/scripts/New-CycloneDxSbom.ps1 new file mode 100644 index 0000000..826b9af --- /dev/null +++ b/.github/scripts/New-CycloneDxSbom.ps1 @@ -0,0 +1,105 @@ +<# +.SYNOPSIS + Generates a CycloneDX SBOM for the project and validates its licence and supplier coverage. + +.DESCRIPTION + Ships alongside the SPDX documents because the two derive their licence data from different places + and neither is complete on its own. + + sbom-tool resolves licences from ClearlyDefined, a network service that harvests package definitions + on demand and degrades to NOASSERTION whenever it is slow or unavailable. CycloneDX reads the licence + expression and authors straight out of each package's own nuspec in the restore cache, so it produces + the same answer every run without a network round trip. + + The two also disagree usefully. ClearlyDefined scans licence file text and can name a licence that a + nuspec only points at by filename; CycloneDX reports authors, which the SPDX documents leave as + NOASSERTION for every dependency. Publishing both is what makes the licence picture complete. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string]$ProjectPath, + + [Parameter(Mandatory)] + [string]$PackageId, + + [Parameter(Mandatory)] + [string]$PackageVersion, + + [Parameter(Mandatory)] + [string]$OutputDirectory, + + # Resolves licences for packages whose nuspec points at a licence file instead of an SPDX expression. + # In Actions, supply secrets.GITHUB_TOKEN; without it those packages are left unlicensed. + [string]$GitHubBearerToken, + + [ValidateRange(0, 1)] + [double]$MinimumLicenseCoverage = 0.9 +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null + +$arguments = @( + $ProjectPath + '--output', $OutputDirectory + '--json' + '--set-name', $PackageId + '--set-version', $PackageVersion + '--set-type', 'Library' + '--exclude-dev' + '--include-license-text' +) + +if ($GitHubBearerToken) { + $env:CYCLONEDX_GITHUB_BEARER_TOKEN = $GitHubBearerToken + $arguments += '--enable-github-licenses' +} +else { + Write-Warning 'No GitHub token supplied; packages that declare a licence file rather than an SPDX expression will be left unlicensed.' +} + +try { + dotnet tool run dotnet-CycloneDX -- @arguments +} +finally { + Remove-Item Env:\CYCLONEDX_GITHUB_BEARER_TOKEN -ErrorAction SilentlyContinue +} + +$bomPath = Join-Path $OutputDirectory 'bom.json' +if (-not (Test-Path -LiteralPath $bomPath -PathType Leaf)) { + throw "CycloneDX did not produce a document at $bomPath." +} + +$bom = Get-Content -LiteralPath $bomPath -Raw | ConvertFrom-Json + +# actions/attest only recognises a CycloneDX document that carries all three of these. +foreach ($required in 'bomFormat', 'specVersion', 'serialNumber') { + if (-not $bom.$required) { + throw "CycloneDX document is missing '$required', so it cannot be consumed as an SBOM predicate." + } +} + +$components = @($bom.components) +if ($components.Count -eq 0) { + throw 'CycloneDX document contains no components.' +} + +$licensed = @($components | Where-Object { $_.licenses }) +$authored = @($components | Where-Object { $_.authors }) +$coverage = $licensed.Count / $components.Count + +$renamedPath = Join-Path $OutputDirectory "$PackageId.$PackageVersion.cdx.json" +Move-Item -LiteralPath $bomPath -Destination $renamedPath -Force + +$checksum = (Get-FileHash -LiteralPath $renamedPath -Algorithm SHA256).Hash.ToLowerInvariant() +Set-Content -LiteralPath "$renamedPath.sha256" -Value $checksum -NoNewline + +Write-Host "CycloneDX $($bom.specVersion): $($components.Count) components, $($licensed.Count) licensed, $($authored.Count) with a supplier, $(@($bom.dependencies).Count) dependency edges." + +if ($coverage -lt $MinimumLicenseCoverage) { + $unlicensed = @($components | Where-Object { -not $_.licenses } | ForEach-Object { "$($_.name)@$($_.version)" }) + Write-Warning "Only $([math]::Round($coverage * 100))% of components carry a licence (threshold $([math]::Round($MinimumLicenseCoverage * 100))%). Unresolved: $($unlicensed -join ', ')" +} diff --git a/.github/scripts/New-Sbom.ps1 b/.github/scripts/New-Sbom.ps1 index c8563b4..6b533d0 100644 --- a/.github/scripts/New-Sbom.ps1 +++ b/.github/scripts/New-Sbom.ps1 @@ -4,8 +4,19 @@ .DESCRIPTION Shared by the release workflow and the on-demand SBOM dry run so both produce identical documents. - 'dotnet tool run' has no --tool-manifest option and discovers the manifest from the current - directory, so the tool is invoked from the folder holding the nested manifest. + + Both formats come out of a single sbom-tool invocation. Generating them separately made the two + documents disagree: each invocation performed its own ClearlyDefined lookup, so one document could + carry licenses while the other carried none, and the second scan detected the first document's + manifest as a component of the build. + + sbom-tool degrades silently when ClearlyDefined is unreachable - it logs a warning, writes + NOASSERTION licenses and still exits 0. ClearlyDefined harvests package definitions on demand, so a + coordinate it has not seen before can stall until the gateway times out, while the same request + succeeds once the definition is cached. Generation is therefore retried while coverage improves. + +.OUTPUTS + Manifests are written to $OutputRoot/_manifest/spdx_2.2 and $OutputRoot/_manifest/spdx_3.0. #> [CmdletBinding()] param( @@ -23,6 +34,7 @@ param( [Parameter(Mandatory)] [string]$BuildComponentPath, + # Must sit outside BuildComponentPath, or the generated manifests become components of the build. [Parameter(Mandatory)] [string]$OutputRoot, @@ -35,7 +47,10 @@ param( [int]$LicenseTimeoutSeconds = 180, - [string]$ToolManifestDirectory = (Join-Path $PSScriptRoot '../../.config/sbom-tool') + [ValidateRange(1, 5)] + [int]$MaxAttempts = 3, + + [int]$RetryDelaySeconds = 15 ) $ErrorActionPreference = 'Stop' @@ -45,33 +60,88 @@ if (-not $NamespaceBaseUri) { $NamespaceBaseUri = "http://spdx.org/spdxdocs/$PackageId" } -$formats = @( - @{ Version = 'SPDX:2.2'; Output = 'spdx-2.2' }, - @{ Version = 'SPDX:3.0'; Output = 'spdx-3.0' } -) +$resolvedOutputRoot = [System.IO.Path]::GetFullPath($OutputRoot) +$resolvedComponentPath = [System.IO.Path]::GetFullPath($BuildComponentPath) + +if ($resolvedOutputRoot.StartsWith($resolvedComponentPath, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "OutputRoot '$resolvedOutputRoot' is inside BuildComponentPath '$resolvedComponentPath'. The generated manifests would be scanned as components of the build." +} + +$spdx22Manifest = Join-Path $resolvedOutputRoot '_manifest/spdx_2.2/manifest.spdx.json' + +function Get-LicenseCoverage { + param([string]$ManifestPath) + + if (-not (Test-Path -LiteralPath $ManifestPath -PathType Leaf)) { + return [pscustomobject]@{ Total = 0; Licensed = 0 } + } + + $packages = @((Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json).packages) + + [pscustomobject]@{ + Total = $packages.Count + Licensed = @($packages | Where-Object { $_.licenseConcluded -and $_.licenseConcluded -ne 'NOASSERTION' }).Count + } +} + +$best = [pscustomobject]@{ Total = 0; Licensed = -1 } +$staging = "$resolvedOutputRoot.attempt" + +for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + # Each attempt is generated aside and only promoted if it improves on the one already kept, so a + # degraded retry can never replace a better document. sbom-tool also will not create -m itself. + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $staging -Force | Out-Null + + Write-Host "Generating SPDX 2.2 and SPDX 3.0 SBOMs (attempt $attempt of $MaxAttempts)..." + + # /Verbosity has to precede the remaining switches or the parser binds its value to another argument. + dotnet tool run sbom-tool -- generate ` + /Verbosity:Information ` + -b $BuildDropPath ` + -bc $BuildComponentPath ` + -m $staging ` + -pn $PackageId ` + -pv $PackageVersion ` + -ps $Supplier ` + -nsb $NamespaceBaseUri ` + -mi 'SPDX:2.2,SPDX:3.0' ` + -li $ResolveLicenses.ToString().ToLowerInvariant() ` + -lto $LicenseTimeoutSeconds ` + -pm true + + $coverage = Get-LicenseCoverage -ManifestPath (Join-Path $staging '_manifest/spdx_2.2/manifest.spdx.json') + Write-Host "Attempt ${attempt}: $($coverage.Licensed) of $($coverage.Total) packages carry a resolved license." + + if ($coverage.Licensed -gt $best.Licensed) { + Remove-Item -LiteralPath $resolvedOutputRoot -Recurse -Force -ErrorAction SilentlyContinue + Move-Item -LiteralPath $staging -Destination $resolvedOutputRoot -Force + $best = $coverage + } + else { + Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue + # Zero is an outage rather than a plateau: a coordinate ClearlyDefined has never harvested only + # becomes available after the request that triggered the harvest has already failed. + if ($coverage.Licensed -gt 0) { + Write-Host 'License coverage stopped improving; keeping the document already generated.' + break + } + } + + if (-not $ResolveLicenses -or ($best.Total -gt 0 -and $best.Licensed -eq $best.Total)) { + break + } -Push-Location -LiteralPath $ToolManifestDirectory -try { - foreach ($format in $formats) { - $manifestDirectory = Join-Path $OutputRoot $format.Output - # sbom-tool requires the manifest directory to exist before generation. - New-Item -ItemType Directory -Path $manifestDirectory -Force | Out-Null - - Write-Host "Generating $($format.Version) SBOM into $manifestDirectory..." - dotnet tool run sbom-tool -- generate ` - -b $BuildDropPath ` - -bc $BuildComponentPath ` - -m $manifestDirectory ` - -pn $PackageId ` - -pv $PackageVersion ` - -ps $Supplier ` - -nsb $NamespaceBaseUri ` - -mi $format.Version ` - -li $ResolveLicenses.ToString().ToLowerInvariant() ` - -lto $LicenseTimeoutSeconds ` - -pm true + if ($attempt -lt $MaxAttempts) { + Write-Host "Retrying in $RetryDelaySeconds seconds to let ClearlyDefined harvest the missing definitions..." + Start-Sleep -Seconds $RetryDelaySeconds } } -finally { - Pop-Location + +if (-not (Test-Path -LiteralPath $spdx22Manifest -PathType Leaf)) { + throw "sbom-tool produced no SPDX 2.2 document at $spdx22Manifest." +} + +if ($ResolveLicenses -and $best.Licensed -lt $best.Total) { + Write-Warning "$($best.Total - $best.Licensed) of $($best.Total) packages have no resolved license and are recorded as NOASSERTION." } diff --git a/.github/workflows/build-and-publish.yml b/.github/workflows/build-and-publish.yml index 048109a..1d9adb9 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -208,16 +208,6 @@ jobs: -ExpectedPublicKeyPath "${{ env.EXPECTED_PUBLIC_KEY_PATH }}" -WorkingDirectory "${{ runner.temp }}/strong-name-validation" - - name: Validate package provenance metadata - shell: pwsh - run: > - .github/scripts/Assert-NuspecRepository.ps1 - -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" - -ExpectedRepositoryUrl "${{ env.REPOSITORY_URL }}" - -ExpectedCommit "${{ github.sha }}" - -ExpectedPackageId "${{ env.PACKAGE_ID }}" - -ExpectedVersion "$env:VERSION" - - name: Restore .NET local tools run: dotnet tool restore @@ -342,9 +332,8 @@ jobs: - name: Restore .NET dependencies run: dotnet restore ${{ env.PROJECT_PATH }} - # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore' used by the sign steps. - - name: Restore sbom-tool (pinned) - run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json + - name: Restore .NET local tools + run: dotnet tool restore - name: Generate SBOMs shell: pwsh @@ -354,16 +343,30 @@ jobs: -PackageVersion "$env:VERSION" -BuildDropPath "${{ github.workspace }}/artifacts" -BuildComponentPath "${{ github.workspace }}" - -OutputRoot "${{ github.workspace }}/sbom" + -OutputRoot "${{ runner.temp }}/sbom" -LicenseTimeoutSeconds ${{ env.SBOM_LICENSE_TIMEOUT_SECONDS }} - name: Verify SBOM output shell: pwsh run: > .github/scripts/Assert-Sbom.ps1 - -OutputRoot "${{ github.workspace }}/sbom" + -OutputRoot "${{ runner.temp }}/sbom" -ExpectedPackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" + # CycloneDX reads licences and authors from each package's own nuspec, so it fills the fields the + # ClearlyDefined-backed SPDX documents leave as NOASSERTION whenever that service is degraded. + - name: Generate CycloneDX SBOM + shell: pwsh + env: + GH_TOKEN_FOR_LICENSES: ${{ github.token }} + run: > + .github/scripts/New-CycloneDxSbom.ps1 + -ProjectPath "${{ env.PROJECT_PATH }}" + -PackageId "${{ env.PACKAGE_ID }}" + -PackageVersion "$env:VERSION" + -OutputDirectory "${{ runner.temp }}/sbom/cyclonedx" + -GitHubBearerToken "$env:GH_TOKEN_FOR_LICENSES" + # An attestation binds to a digest rather than a path, so the bytes are re-hashed here and that # value is what gets attested, re-checked before publishing, and reported on the release. - name: Reverify package before attestation @@ -384,29 +387,40 @@ jobs: subject-digest: sha256:${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} # actions/attest derives the predicate from an SPDX 2.x or CycloneDX document; SPDX 3.0 ships as evidence only. - - name: Attest SBOM - id: attest-sbom + - name: Attest SPDX SBOM + id: attest-sbom-spdx + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg + subject-digest: sha256:${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} + sbom-path: ${{ runner.temp }}/sbom/_manifest/spdx_2.2/manifest.spdx.json + + # Distinct predicate type from the SPDX attestation, so both bind to the same digest without colliding. + - name: Attest CycloneDX SBOM + id: attest-sbom-cyclonedx uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: subject-name: ${{ env.PACKAGE_ID }}.${{ env.VERSION }}.nupkg subject-digest: sha256:${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} - sbom-path: sbom/spdx-2.2/_manifest/spdx_2.2/manifest.spdx.json + sbom-path: ${{ runner.temp }}/sbom/cyclonedx/${{ env.PACKAGE_ID }}.${{ env.VERSION }}.cdx.json - name: Collect attestation bundles shell: pwsh run: > .github/scripts/Copy-AttestationBundles.ps1 -ProvenanceBundlePath "${{ steps.attest-provenance.outputs.bundle-path }}" - -SbomBundlePath "${{ steps.attest-sbom.outputs.bundle-path }}" - -Destination "${{ github.workspace }}/sbom/attestations" + -SpdxBundlePath "${{ steps.attest-sbom-spdx.outputs.bundle-path }}" + -CycloneDxBundlePath "${{ steps.attest-sbom-cyclonedx.outputs.bundle-path }}" + -Destination "${{ runner.temp }}/sbom/attestations" -ProvenanceUrl "${{ steps.attest-provenance.outputs.attestation-url }}" - -SbomUrl "${{ steps.attest-sbom.outputs.attestation-url }}" + -SpdxUrl "${{ steps.attest-sbom-spdx.outputs.attestation-url }}" + -CycloneDxUrl "${{ steps.attest-sbom-cyclonedx.outputs.attestation-url }}" - name: Upload SBOM and attestations uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sbom - path: sbom/** + path: ${{ runner.temp }}/sbom/** retention-days: 30 if-no-files-found: error @@ -511,8 +525,8 @@ jobs: run: | set -euo pipefail - (cd sbom/spdx-2.2/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-2.2.zip" .) - (cd sbom/spdx-3.0/_manifest && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-3.0.zip" .) + (cd sbom/_manifest/spdx_2.2 && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-2.2.zip" .) + (cd sbom/_manifest/spdx_3.0 && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.spdx-3.0.zip" .) (cd evidence/dependency-scan && zip -r "${GITHUB_WORKSPACE}/${PACKAGE_ID}.${TAG}.dependency-scan.zip" .) gh release upload "$TAG" --clobber -R "${{ github.repository }}" \ @@ -521,5 +535,8 @@ jobs: "${PACKAGE_ID}.${TAG}.spdx-2.2.zip" \ "${PACKAGE_ID}.${TAG}.spdx-3.0.zip" \ "${PACKAGE_ID}.${TAG}.dependency-scan.zip" \ + "sbom/cyclonedx/${PACKAGE_ID}.${TAG}.cdx.json" \ + "sbom/cyclonedx/${PACKAGE_ID}.${TAG}.cdx.json.sha256" \ "sbom/attestations/provenance.sigstore.json" \ - "sbom/attestations/sbom.sigstore.json" + "sbom/attestations/sbom-spdx.sigstore.json" \ + "sbom/attestations/sbom-cyclonedx.sigstore.json" diff --git a/.github/workflows/sbom.yml b/.github/workflows/sbom.yml index cc076d1..ef42511 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -47,9 +47,8 @@ jobs: -p:Version=${{ env.PACKAGE_VERSION }} -o "${{ github.workspace }}/artifacts" - # Dedicated nested manifest keeps sbom-tool out of the root 'dotnet tool restore' used by build-and-publish.yml - - name: Restore sbom-tool (pinned) - run: dotnet tool restore --tool-manifest .config/sbom-tool/dotnet-tools.json + - name: Restore .NET local tools + run: dotnet tool restore - name: Generate SBOMs shell: pwsh @@ -59,20 +58,32 @@ jobs: -PackageVersion "${{ env.PACKAGE_VERSION }}" -BuildDropPath "${{ github.workspace }}/artifacts" -BuildComponentPath "${{ github.workspace }}" - -OutputRoot "${{ github.workspace }}/sbom" + -OutputRoot "${{ runner.temp }}/sbom" -LicenseTimeoutSeconds ${{ env.SBOM_LICENSE_TIMEOUT_SECONDS }} - name: Verify SBOM output shell: pwsh run: > .github/scripts/Assert-Sbom.ps1 - -OutputRoot "${{ github.workspace }}/sbom" + -OutputRoot "${{ runner.temp }}/sbom" -ExpectedPackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:PACKAGE_VERSION}.nupkg" + - name: Generate CycloneDX SBOM + shell: pwsh + env: + GH_TOKEN_FOR_LICENSES: ${{ github.token }} + run: > + .github/scripts/New-CycloneDxSbom.ps1 + -ProjectPath "${{ env.PROJECT_PATH }}" + -PackageId "${{ env.PACKAGE_ID }}" + -PackageVersion "${{ env.PACKAGE_VERSION }}" + -OutputDirectory "${{ runner.temp }}/sbom/cyclonedx" + -GitHubBearerToken "$env:GH_TOKEN_FOR_LICENSES" + - name: Upload SBOM files uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: sbom - path: sbom/** + path: ${{ runner.temp }}/sbom/** retention-days: 1 if-no-files-found: error From 39eea9348f4446f9c3b953e70e01ef4a068bccd6 Mon Sep 17 00:00:00 2001 From: Borislav Traykov Date: Thu, 3 Sep 2026 14:14:55 +0300 Subject: [PATCH 22/22] Tweak author counting for CycloneDX SBOM and fixed path segment checking for the SPDX SBOM generation --- .github/scripts/New-CycloneDxSbom.ps1 | 4 +++- .github/scripts/New-Sbom.ps1 | 31 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/scripts/New-CycloneDxSbom.ps1 b/.github/scripts/New-CycloneDxSbom.ps1 index 826b9af..36b64b7 100644 --- a/.github/scripts/New-CycloneDxSbom.ps1 +++ b/.github/scripts/New-CycloneDxSbom.ps1 @@ -88,6 +88,8 @@ if ($components.Count -eq 0) { } $licensed = @($components | Where-Object { $_.licenses }) +# CycloneDX models 'authors' (people, from the nuspec author metadata) and 'supplier' (an organisation) +# separately; cyclonedx-dotnet only ever populates the former, so this is reported as author coverage. $authored = @($components | Where-Object { $_.authors }) $coverage = $licensed.Count / $components.Count @@ -97,7 +99,7 @@ Move-Item -LiteralPath $bomPath -Destination $renamedPath -Force $checksum = (Get-FileHash -LiteralPath $renamedPath -Algorithm SHA256).Hash.ToLowerInvariant() Set-Content -LiteralPath "$renamedPath.sha256" -Value $checksum -NoNewline -Write-Host "CycloneDX $($bom.specVersion): $($components.Count) components, $($licensed.Count) licensed, $($authored.Count) with a supplier, $(@($bom.dependencies).Count) dependency edges." +Write-Host "CycloneDX $($bom.specVersion): $($components.Count) components, $($licensed.Count) licensed, $($authored.Count) with an author, $(@($bom.dependencies).Count) dependency edges." if ($coverage -lt $MinimumLicenseCoverage) { $unlicensed = @($components | Where-Object { -not $_.licenses } | ForEach-Object { "$($_.name)@$($_.version)" }) diff --git a/.github/scripts/New-Sbom.ps1 b/.github/scripts/New-Sbom.ps1 index 6b533d0..8333990 100644 --- a/.github/scripts/New-Sbom.ps1 +++ b/.github/scripts/New-Sbom.ps1 @@ -63,7 +63,36 @@ if (-not $NamespaceBaseUri) { $resolvedOutputRoot = [System.IO.Path]::GetFullPath($OutputRoot) $resolvedComponentPath = [System.IO.Path]::GetFullPath($BuildComponentPath) -if ($resolvedOutputRoot.StartsWith($resolvedComponentPath, [System.StringComparison]::OrdinalIgnoreCase)) { +function Test-PathIsWithin { + param( + [Parameter(Mandatory)] + [string]$Path, + + [Parameter(Mandatory)] + [string]$PotentialParent + ) + + # Compares directory segments rather than a raw string prefix, so a sibling directory whose name + # merely starts with the same characters (e.g. 'repo-output' next to 'repo') is not flagged as nested. + [char[]]$separators = [System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar + $pathSegments = $Path.Split($separators, [System.StringSplitOptions]::RemoveEmptyEntries) + $parentSegments = $PotentialParent.Split($separators, [System.StringSplitOptions]::RemoveEmptyEntries) + + if ($pathSegments.Count -lt $parentSegments.Count) { + return $false + } + + for ($i = 0; $i -lt $parentSegments.Count; $i++) { + # PowerShell string comparison operators are case-insensitive by default. + if ($pathSegments[$i] -ne $parentSegments[$i]) { + return $false + } + } + + return $true +} + +if (Test-PathIsWithin -Path $resolvedOutputRoot -PotentialParent $resolvedComponentPath) { throw "OutputRoot '$resolvedOutputRoot' is inside BuildComponentPath '$resolvedComponentPath'. The generated manifests would be scanned as components of the build." }