Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .config/sbom-tool/dotnet-tools.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 1,
"isRoot": true,
"tools": {
"microsoft.sbom.dotnettool": {
"version": "4.1.5",
"commands": ["sbom-tool"],
"rollForward": true
}
}
}
124 changes: 124 additions & 0 deletions .github/scripts/Assert-NuspecRepository.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# Asserts a packed NuGet package carries the provenance metadata consumers rely on.
# The nuspec is generated from MSBuild properties at pack time, so an unset one yields a package that
# restores fine but cannot be traced to source -- 0.1.1 shipped a commit with no repository url.
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PackagePath,

[Parameter(Mandatory)]
[string]$ExpectedRepositoryUrl,

[Parameter(Mandatory)]
[string]$ExpectedCommit,

[string]$ExpectedPackageId,

[string]$ExpectedVersion
)

$ErrorActionPreference = 'Stop'

if (-not (Test-Path -LiteralPath $PackagePath -PathType Leaf)) {
throw "NuGet package not found: $PackagePath"
}

if ($ExpectedCommit -notmatch '^[0-9a-fA-F]{40}$') {
throw "ExpectedCommit must be a full 40-character git SHA, but was '$ExpectedCommit'."
}

Add-Type -AssemblyName System.IO.Compression.FileSystem

$archive = [System.IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath).ProviderPath)
try {
$entry = $archive.Entries |
Where-Object { $_.FullName -notlike '*/*' -and $_.FullName -like '*.nuspec' } |
Select-Object -First 1

if ($null -eq $entry) {
throw "No .nuspec found at the root of $PackagePath."
}

$reader = New-Object System.IO.StreamReader($entry.Open())
try {
$nuspecXml = $reader.ReadToEnd()
}
finally {
$reader.Dispose()
}
}
finally {
$archive.Dispose()
}

$document = New-Object System.Xml.XmlDocument
$document.PreserveWhitespace = $false
$document.LoadXml($nuspecXml)

# The nuspec default namespace changes with the schema version, so match on local names only.
$metadata = $document.SelectSingleNode('/*[local-name()="package"]/*[local-name()="metadata"]')
if ($null -eq $metadata) {
throw "The nuspec in $PackagePath has no <metadata> element."
}

function Get-MetadataValue([string]$Name) {
$node = $metadata.SelectSingleNode("*[local-name()=`"$Name`"]")
if ($null -eq $node) { return $null }
return $node.InnerText.Trim()
}

$problems = @()

function Assert-Value([string]$Label, [string]$Actual, [string]$Expected) {
if ([string]::IsNullOrWhiteSpace($Actual)) {
$script:problems += "$Label is missing from the nuspec."
}
elseif ($Expected -and $Actual -ne $Expected) {
$script:problems += "$Label is '$Actual', expected '$Expected'."
}
}

$repository = $metadata.SelectSingleNode('*[local-name()="repository"]')
if ($null -eq $repository) {
$problems += '<repository> is missing from the nuspec.'
}
else {
Assert-Value 'repository/@type' $repository.GetAttribute('type') 'git'
Assert-Value 'repository/@url' $repository.GetAttribute('url') $ExpectedRepositoryUrl
Assert-Value 'repository/@commit' $repository.GetAttribute('commit') $ExpectedCommit
}

Assert-Value 'authors' (Get-MetadataValue 'authors') $null
Assert-Value 'projectUrl' (Get-MetadataValue 'projectUrl') $null
Assert-Value 'description' (Get-MetadataValue 'description') $null

if ($ExpectedPackageId) {
Assert-Value 'id' (Get-MetadataValue 'id') $ExpectedPackageId
}

if ($ExpectedVersion) {
Assert-Value 'version' (Get-MetadataValue 'version') $ExpectedVersion
}

$license = $metadata.SelectSingleNode('*[local-name()="license"]')
if ($null -eq $license) {
$problems += '<license> is missing from the nuspec.'
}
elseif ($license.GetAttribute('type') -ne 'expression') {
$problems += "license/@type is '$($license.GetAttribute('type'))', expected 'expression'."
}

# 'authors' defaults to the assembly name when <Authors> is unset, which is not an author.
$authors = Get-MetadataValue 'authors'
if ($authors -and $ExpectedPackageId -and $authors -eq $ExpectedPackageId) {
$problems += "authors is '$authors', which is the package id rather than a real author. Set <Authors> in the project file."
}

if ($problems.Count -gt 0) {
throw "Package provenance metadata validation failed for $([System.IO.Path]::GetFileName($PackagePath)):`n- $($problems -join "`n- ")"
}

Write-Host "Verified nuspec provenance for $([System.IO.Path]::GetFileName($PackagePath)):"
Write-Host " repository url : $($repository.GetAttribute('url'))"
Write-Host " repository commit : $($repository.GetAttribute('commit'))"
Write-Host " authors : $authors"
131 changes: 131 additions & 0 deletions .github/scripts/verify-strong-name.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Verifies assemblies are strong-name signed with the approved Infragistics key.
# 'sn.exe -vf' only proves a strong name is internally consistent, so any valid private key passes;
# this also compares each assembly's public key against the value pinned in the repository.
[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."
31 changes: 27 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,41 @@ permissions:
contents: read

jobs:
# Blocks a pull request that would introduce a High or Critical advisory, or a
# dependency under a license the package cannot ship. Pushes to master skip it —
# the action needs the two-commit range a pull request gives it.
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 repository
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: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v7.0.1
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

# The SDK is pinned via global.json (10.0.x builds all TFMs);
# older runtimes are needed to run tests targeting net8.0/net9.0.
- name: Setup .NET
uses: actions/setup-dotnet@v6.0.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: |
8.0.x
Expand All @@ -36,7 +59,7 @@ jobs:
run: dotnet format whitespace . --folder --exclude templates node_modules --verify-no-changes

- name: Setup Node.js
uses: actions/setup-node@v7.0.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
cache: npm
Expand Down Expand Up @@ -90,7 +113,7 @@ jobs:
run: cat CoverageReport/SummaryGithub.md >> "$GITHUB_STEP_SUMMARY"

- name: Upload coverage report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-report
path: CoverageReport
Expand Down
Loading