Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
57991a6
Refactor release workflow with advisory practices
turbobobbytraykov Aug 24, 2026
8ee932f
Include hidden files for hash verification as well
turbobobbytraykov Aug 24, 2026
d6600cb
re-use the gh env for the sign-assemblies job - for now
turbobobbytraykov Aug 24, 2026
d28a2bb
explicitly create the sbom output dirs
turbobobbytraykov Aug 24, 2026
ea3ea17
drop the extra explicit shallow clone - it's already implicit
turbobobbytraykov Aug 24, 2026
63886c0
tweaks and security hardening
turbobobbytraykov Aug 24, 2026
6b9aaa6
Try adding a pinned public snk for better verification and auditing
turbobobbytraykov Aug 25, 2026
9317613
tweaks
turbobobbytraykov Aug 25, 2026
3d7cff3
Further refactoring and simplification
turbobobbytraykov Aug 26, 2026
b7c5817
Fix the SBOM manifest dir
turbobobbytraykov Aug 26, 2026
2ae96ce
Improve validations in the release workflow
turbobobbytraykov Aug 27, 2026
8418abd
Update the version of the sbom-tool
turbobobbytraykov Sep 1, 2026
39eebd0
Ignore the sbom dir
turbobobbytraykov Sep 1, 2026
207e40f
Pin the dotnet major version and roll forward on the latest feature b…
turbobobbytraykov Sep 1, 2026
c516a2a
Some tab character-trimming in the SqlGenerator class
turbobobbytraykov Sep 1, 2026
83678c4
Apply lessons learned from the Blazor lite repos + Damyan preferences…
turbobobbytraykov Sep 1, 2026
03789db
metadata tweaks to the csproj - for the resulting NuGet package
turbobobbytraykov Sep 1, 2026
4666b82
Address local code review findings
turbobobbytraykov Sep 1, 2026
7ca760f
Merge branch 'master' into btraykov/supply-chain-provenance-sbom
turbobobbytraykov Sep 1, 2026
7bfa502
Address the package cache issue that decided to pop up today
turbobobbytraykov Sep 1, 2026
c94ffec
Merge branch 'btraykov/supply-chain-provenance-sbom' of https://githu…
turbobobbytraykov Sep 1, 2026
37b7694
Reword where the NuGet API key is sourced from - to avoid a potential…
turbobobbytraykov Sep 1, 2026
8824857
Refactoring and introduce CycloneDX SBOM
turbobobbytraykov Sep 2, 2026
50b724b
Merge branch 'btraykov/supply-chain-provenance-sbom' of https://githu…
turbobobbytraykov Sep 2, 2026
39eea93
Tweak author counting for CycloneDX SBOM and fixed path segment check…
turbobobbytraykov Sep 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}
13 changes: 0 additions & 13 deletions .config/sbom-tool/dotnet-tools.json

This file was deleted.

137 changes: 137 additions & 0 deletions .github/scripts/Assert-AssemblyStrongName.ps1
Original file line number Diff line number Diff line change
@@ -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(?<version>\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."
77 changes: 77 additions & 0 deletions .github/scripts/Assert-AuthenticodeSignature.ps1
Original file line number Diff line number Diff line change
@@ -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
}
46 changes: 46 additions & 0 deletions .github/scripts/Assert-NuGetSignature.ps1
Original file line number Diff line number Diff line change
@@ -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."
39 changes: 39 additions & 0 deletions .github/scripts/Assert-PackageStrongName.ps1
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 25 additions & 0 deletions .github/scripts/Assert-ReleaseVersion.ps1
Original file line number Diff line number Diff line change
@@ -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."
Loading
Loading