diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index d6d9df7..082d426 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -3,10 +3,25 @@ "isRoot": true, "tools": { "sign": { - "version": "0.9.1-beta.25379.1", + "version": "0.9.1-beta.26330.1", "commands": [ "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/Assert-AssemblyStrongName.ps1 b/.github/scripts/Assert-AssemblyStrongName.ps1 new file mode 100644 index 0000000..0be5287 --- /dev/null +++ b/.github/scripts/Assert-AssemblyStrongName.ps1 @@ -0,0 +1,137 @@ +<# +.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. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string[]]$Path, + + [Parameter(Mandatory)] + [string]$ExpectedPublicKeyPath, + + [string]$SnPath +) + +$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 ($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. +$digest = [System.Security.Cryptography.SHA1]::Create().ComputeHash($expectedPublicKey) +$tokenBytes = $digest[-8..-1] +[array]::Reverse($tokenBytes) +$expectedToken = ConvertTo-HexString $tokenBytes + +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 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." +} + +$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/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-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-PackageStrongName.ps1 b/.github/scripts/Assert-PackageStrongName.ps1 new file mode 100644 index 0000000..f0890ed --- /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 'Assert-AssemblyStrongName.ps1') -Path $extractPath -ExpectedPublicKeyPath $ExpectedPublicKeyPath +} +finally { + Remove-Item -LiteralPath $WorkingDirectory -Recurse -Force -ErrorAction SilentlyContinue +} 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 new file mode 100644 index 0000000..fec3784 --- /dev/null +++ b/.github/scripts/Assert-Sbom.ps1 @@ -0,0 +1,167 @@ +<# +.SYNOPSIS + Verifies the generated SPDX 2.2 and 3.0 manifests and their relationship to the shipped package. + +.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( + [Parameter(Mandatory)] + [string]$OutputRoot, + + [Parameter(Mandatory)] + [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' + +$manifests = @( + [pscustomobject]@{ + Name = 'SPDX 2.2' + Path = Join-Path $OutputRoot '_manifest/spdx_2.2/manifest.spdx.json' + }, + [pscustomobject]@{ + Name = 'SPDX 3.0' + Path = Join-Path $OutputRoot '_manifest/spdx_3.0/manifest.spdx.json' + } +) + +$problems = @() +$documents = @{} +foreach ($manifest in $manifests) { + 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 ($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" +} +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 + } + ) + + 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." + } + } +} + +if ($problems.Count -gt 0) { + throw "SBOM validation failed:`n- $($problems -join "`n- ")" +} + +$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 new file mode 100644 index 0000000..5209209 --- /dev/null +++ b/.github/scripts/Copy-AttestationBundles.ps1 @@ -0,0 +1,49 @@ +<# +.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. + + 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( + [Parameter(Mandatory)] + [string]$ProvenanceBundlePath, + + [Parameter(Mandatory)] + [string]$SpdxBundlePath, + + [Parameter(Mandatory)] + [string]$CycloneDxBundlePath, + + [Parameter(Mandatory)] + [string]$Destination, + + [string]$ProvenanceUrl, + + [string]$SpdxUrl, + + [string]$CycloneDxUrl, + + [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 $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 (SPDX 2.2): $SpdxUrl", + "- SBOM (CycloneDX): $CycloneDxUrl" + ) | 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-CycloneDxSbom.ps1 b/.github/scripts/New-CycloneDxSbom.ps1 new file mode 100644 index 0000000..36b64b7 --- /dev/null +++ b/.github/scripts/New-CycloneDxSbom.ps1 @@ -0,0 +1,107 @@ +<# +.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 }) +# 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 + +$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 an author, $(@($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 new file mode 100644 index 0000000..8333990 --- /dev/null +++ b/.github/scripts/New-Sbom.ps1 @@ -0,0 +1,176 @@ +<# +.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. + + 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( + [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, + + # Must sit outside BuildComponentPath, or the generated manifests become components of the build. + [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, + + [ValidateRange(1, 5)] + [int]$MaxAttempts = 3, + + [int]$RetryDelaySeconds = 15 +) + +$ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true + +if (-not $NamespaceBaseUri) { + $NamespaceBaseUri = "http://spdx.org/spdxdocs/$PackageId" +} + +$resolvedOutputRoot = [System.IO.Path]::GetFullPath($OutputRoot) +$resolvedComponentPath = [System.IO.Path]::GetFullPath($BuildComponentPath) + +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." +} + +$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 + } + + if ($attempt -lt $MaxAttempts) { + Write-Host "Retrying in $RetryDelaySeconds seconds to let ClearlyDefined harvest the missing definitions..." + Start-Sleep -Seconds $RetryDelaySeconds + } +} + +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/scripts/Publish-NuGetPackage.ps1 b/.github/scripts/Publish-NuGetPackage.ps1 new file mode 100644 index 0000000..c526a28 --- /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 sourced from NUGET_API_KEY rather than embedded in the workflow command. +#> +[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 54458e9..1d9adb9 100644 --- a/.github/workflows/build-and-publish.yml +++ b/.github/workflows/build-and-publish.yml @@ -2,33 +2,53 @@ name: Build and Publish on: release: - types: [created] - + types: [published] + +permissions: {} + +concurrency: + group: release-${{ github.ref_name }} + env: BUILD_CONFIGURATION: Release DOTNET_VERSION: '9.x' VERSION: ${{ github.ref_name }} + 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' 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 + timeout-minutes: 20 permissions: - id-token: write contents: read steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - fetch-depth: 0 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: dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore .NET dependencies + run: dotnet restore ${{ env.PROJECT_PATH }} + - name: Restore strong-name key shell: pwsh env: @@ -43,23 +63,60 @@ jobs: - name: Build strong-named assemblies 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" + 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() - 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: + name: build-output + path: | + bin/** + obj/** + include-hidden-files: true + retention-days: 1 + if-no-files-found: error + + sign-assemblies: + 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: + contents: read + id-token: write + + 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: Download build output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-output + path: . + digest-mismatch: error - 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 }} @@ -77,73 +134,95 @@ jobs: - name: Validate DLL signatures shell: pwsh - run: | - $dlls = Get-ChildItem -Path "${{ github.workspace }}\bin\${{ env.BUILD_CONFIGURATION }}" -Filter "*.dll" -Recurse - $failed = @() - foreach ($dll in $dlls) { - $sig = Get-AuthenticodeSignature $dll.FullName - if ($sig.Status -ne 'Valid') { - $failed += $dll.FullName - } - } - if ($failed.Count -gt 0) { - Write-Error "Unsigned DLLs found:`n$($failed -join "`n")" - exit 1 - } - Write-Host "All DLLs signed successfully." + 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 + with: + name: signed-assemblies + path: | + bin/** + obj/** + include-hidden-files: true + retention-days: 1 + if-no-files-found: error + + pack: + name: Pack and sign package + needs: sign-assemblies + runs-on: windows-latest + timeout-minutes: 20 + environment: nuget-org-publish + permissions: + contents: read + id-token: write + outputs: + nupkg-sha256: ${{ steps.digest.outputs.nupkg-sha256 }} + 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: Download signed assemblies + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: signed-assemblies + 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 - run: dotnet pack ./Infragistics.QueryBuilder.Executor.csproj --no-build --no-restore --configuration ${{ env.BUILD_CONFIGURATION }} -p:PackageVersion=${{ env.VERSION }} -o "${{ github.workspace }}/nupkg" + shell: pwsh + run: > + dotnet pack ${{ env.PROJECT_PATH }} + --configuration ${{ env.BUILD_CONFIGURATION }} + --no-build + --no-restore + "-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 - - $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." - } - finally { - Remove-Item $validationRoot -Recurse -Force -ErrorAction SilentlyContinue - } + run: > + .github/scripts/Assert-PackageStrongName.ps1 + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" + -ExpectedPublicKeyPath "${{ env.EXPECTED_PUBLIC_KEY_PATH }}" + -WorkingDirectory "${{ runner.temp }}/strong-name-validation" + + - 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: > 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 @@ -151,13 +230,313 @@ jobs: --verbosity Warning - name: Validate NuGet package signature - run: dotnet nuget verify "${{ github.workspace }}/nupkg/Infragistics.QueryBuilder.Executor.${{ 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 + id: digest + shell: pwsh + run: > + .github/scripts/Get-PackageDigest.ps1 + -PackagePath "${env: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: 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 + + sbom: + name: Generate SBOM and attest + needs: pack + runs-on: windows-latest + timeout-minutes: 20 + permissions: + contents: read + id-token: write + attestations: write + outputs: + attested-sha256: ${{ steps.verify-before-attestation.outputs.nupkg-sha256 }} + + 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: Download signed package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nupkg-signed + path: artifacts + + - name: Verify signed package digest + shell: pwsh + run: > + .github/scripts/Get-PackageDigest.ps1 + -PackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:VERSION}.nupkg" + -ExpectedSha256 "${{ needs.pack.outputs.nupkg-sha256 }}" + + - name: Restore .NET dependencies + run: dotnet restore ${{ env.PROJECT_PATH }} + + - name: Restore .NET local tools + run: dotnet tool restore + + - name: Generate SBOMs + shell: pwsh + run: > + .github/scripts/New-Sbom.ps1 + -PackageId "${{ env.PACKAGE_ID }}" + -PackageVersion "$env:VERSION" + -BuildDropPath "${{ github.workspace }}/artifacts" + -BuildComponentPath "${{ github.workspace }}" + -OutputRoot "${{ runner.temp }}/sbom" + -LicenseTimeoutSeconds ${{ env.SBOM_LICENSE_TIMEOUT_SECONDS }} + + - name: Verify SBOM output + shell: pwsh + run: > + .github/scripts/Assert-Sbom.ps1 + -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 + id: verify-before-attestation + shell: pwsh + run: > + .github/scripts/Get-PackageDigest.ps1 + -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' + + - name: Attest build provenance + id: attest-provenance + 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 }} + + # actions/attest derives the predicate from an SPDX 2.x or CycloneDX document; SPDX 3.0 ships as evidence only. + - 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: ${{ 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 }}" + -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 }}" + -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: ${{ runner.temp }}/sbom/** + retention-days: 30 + if-no-files-found: error + + # 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, dependency-scan] + runs-on: windows-latest + timeout-minutes: 15 + environment: nuget-org-publish + permissions: + contents: read + id-token: write + + steps: + - name: Checkout release scripts + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: | + .github/scripts + eng/IG.authenticode-certificates.sha256 + sparse-checkout-cone-mode: 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: artifacts + + - name: Verify the package that was packed, signed, and attested + shell: pwsh + run: > + .github/scripts/Get-PackageDigest.ps1 + -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 + 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 + 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" + shell: pwsh + env: + NUGET_API_KEY: ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + run: > + .github/scripts/Publish-NuGetPackage.ps1 + -PackagePath "${env: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, dependency-scan, publish] + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: write + + steps: + - name: Download signed package + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: nupkg-signed + path: artifacts + + - name: Download SBOM and attestations + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + 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 }} + run: | + set -euo pipefail + + (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 }}" \ + "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/cyclonedx/${PACKAGE_ID}.${TAG}.cdx.json" \ + "sbom/cyclonedx/${PACKAGE_ID}.${TAG}.cdx.json.sha256" \ + "sbom/attestations/provenance.sigstore.json" \ + "sbom/attestations/sbom-spdx.sigstore.json" \ + "sbom/attestations/sbom-cyclonedx.sigstore.json" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75cb81a..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@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 cbb34b4..ef42511 100644 --- a/.github/workflows/sbom.yml +++ b/.github/workflows/sbom.yml @@ -1,33 +1,39 @@ 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. 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] - 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_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: - if: github.event_name == 'release' || github.event.label.name == 'generate sbom' + if: github.event.label.name == 'generate sbom' runs-on: ubuntu-latest + timeout-minutes: 15 steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup .NET uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 @@ -36,74 +42,48 @@ 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 + - name: Restore .NET local tools + run: dotnet tool restore - # -b: the shipped artifact (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 - working-directory: .config/sbom-tool + - name: Generate SBOMs + shell: pwsh run: > - dotnet tool run sbom-tool -- generate - -b ${{ github.workspace }}/artifacts - -bc ${{ github.workspace }} - -pn Infragistics.QueryBuilder.Executor - -pv ${{ env.PACKAGE_VERSION }} - -ps "Infragistics Inc." - -nsb http://spdx.org/spdxdocs/Infragistics.QueryBuilder.Executor - -mi SPDX:3.0 - -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 - echo "SBOM generated successfully." + .github/scripts/New-Sbom.ps1 + -PackageId "${{ env.PACKAGE_ID }}" + -PackageVersion "${{ env.PACKAGE_VERSION }}" + -BuildDropPath "${{ github.workspace }}/artifacts" + -BuildComponentPath "${{ github.workspace }}" + -OutputRoot "${{ runner.temp }}/sbom" + -LicenseTimeoutSeconds ${{ env.SBOM_LICENSE_TIMEOUT_SECONDS }} + + - name: Verify SBOM output + shell: pwsh + run: > + .github/scripts/Assert-Sbom.ps1 + -OutputRoot "${{ runner.temp }}/sbom" + -ExpectedPackagePath "${env:GITHUB_WORKSPACE}/artifacts/${env:PACKAGE_ID}.${env:PACKAGE_VERSION}.nupkg" - - 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: 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-spdx_3.0 - path: artifacts/_manifest/spdx_3.0 + name: sbom + path: ${{ runner.temp }}/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 }}" 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 diff --git a/Infragistics.QueryBuilder.Executor.csproj b/Infragistics.QueryBuilder.Executor.csproj index 724d263..8bfdd62 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 - - + + 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}", }; } } 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 diff --git a/eng/IG.publickey.hex b/eng/IG.publickey.hex new file mode 100644 index 0000000..fbc393f --- /dev/null +++ b/eng/IG.publickey.hex @@ -0,0 +1,10 @@ +# 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 +# +# Re-derive with: sn -Tp +002400000480000094000000060200000024000052534131000400000100010001afa6285b0af5cdd03aa2b6fdaf33fc4759cf9cd9bcf8b778ae60b9fcf71fc8126b78dbf930519614013b7999297907dd9c00bcc487a14f4c6733fe9adb96c053f005d7148f1666fcb882a0f9ba4307c85694b3322889dab357ad5cefd72ccc45e1b6973bdd2f15b2a300077b8d9de30739200887c5407c8a68c90345cbc4f1 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" + } +}