diff --git a/.azure-pipelines/hidi-release.yml b/.azure-pipelines/hidi-release.yml index 0087ca80..c84fefd3 100644 --- a/.azure-pipelines/hidi-release.yml +++ b/.azure-pipelines/hidi-release.yml @@ -165,11 +165,12 @@ extends: MaxRetryAttempts: '5' PendingAnalysisWaitTimeoutMinutes: '5' - pwsh: | - Copy-Item artifacts\hidi\signing\Microsoft.OpenApi.Hidi.dll src\Microsoft.OpenApi.Hidi\bin\$(buildConfiguration)\net8.0\Microsoft.OpenApi.Hidi.dll + $ErrorActionPreference = 'Stop' Copy-Item artifacts\hidi\signing\Microsoft.OpenApi.Hidi.exe artifacts\hidi\win-x64\Microsoft.OpenApi.Hidi.exe New-Item -ItemType Directory -Force '$(Build.ArtifactStagingDirectory)\hidi' | Out-Null - dotnet pack src\Microsoft.OpenApi.Hidi\Microsoft.OpenApi.Hidi.csproj -c $(buildConfiguration) --no-build --include-symbols --include-source /p:SymbolPackageFormat=snupkg -o '$(Build.ArtifactStagingDirectory)\hidi' + dotnet pack src\Microsoft.OpenApi.Hidi\Microsoft.OpenApi.Hidi.csproj -c $(buildConfiguration) --no-build --include-symbols --include-source /p:SymbolPackageFormat=snupkg "/p:HidiSignedAssemblyPath=$(Build.SourcesDirectory)\artifacts\hidi\signing\Microsoft.OpenApi.Hidi.dll" -o '$(Build.ArtifactStagingDirectory)\hidi' if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & .\scripts\verify-hidi-package-assembly.ps1 -PackagePath '$(Build.ArtifactStagingDirectory)\hidi\Microsoft.OpenApi.Hidi.$(HidiVersion).nupkg' -SignedAssemblyPath '$(Build.SourcesDirectory)\artifacts\hidi\signing\Microsoft.OpenApi.Hidi.dll' Compress-Archive -Path artifacts\hidi\win-x64\* -DestinationPath '$(Build.ArtifactStagingDirectory)\hidi\hidi-win-x64-$(HidiVersion).zip' Copy-Item artifacts\hidi\win-x64\Microsoft.OpenApi.Hidi.exe '$(Build.ArtifactStagingDirectory)\hidi\Microsoft.OpenApi.Hidi.exe' displayName: Pack signed hidi binaries diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index afa7c5c2..3fc94750 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -26,10 +26,12 @@ jobs: - name: Data gatherer id: data_gatherer shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} run: | # Get default branch $repo = 'microsoft/OpenAPI.NET.OData' - $defaultBranch = Invoke-RestMethod -Method GET -Uri https://api.github.com/repos/$repo | Select-Object -ExpandProperty default_branch + $defaultBranch = Invoke-RestMethod -Method GET -Uri https://api.github.com/repos/$repo -Headers @{ Authorization = "Bearer $env:GITHUB_TOKEN" } | Select-Object -ExpandProperty default_branch Write-Output "default_branch=$(echo $defaultBranch) >> $GITHUB_OUTPUT" - name: Conditionals handler diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index d717f338..3681312c 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -60,59 +60,65 @@ jobs: - name: Install PowerShell test dependency shell: pwsh run: Install-Module Pester -RequiredVersion 5.7.1 -Scope CurrentUser -Force - - name: Test Hidi NuGet helper with measured coverage + - name: Test Hidi NuGet helpers with measured coverage shell: pwsh run: | $ErrorActionPreference = 'Stop' Import-Module Pester -RequiredVersion 5.7.1 -ErrorAction Stop - $helperPath = 'scripts/check-nuget-package-published.ps1' - $helper = (Resolve-Path $helperPath).Path + $helperPaths = @('scripts/check-nuget-package-published.ps1', 'scripts/verify-hidi-package-assembly.ps1') + $helpers = @($helperPaths | ForEach-Object { (Resolve-Path $_).Path }) $outputRoot = Join-Path $PWD 'artifacts\hidi\powershell-coverage' New-Item -ItemType Directory -Force -Path $outputRoot | Out-Null $configuration = New-PesterConfiguration - $configuration.Run.Path = 'test\Microsoft.OpenApi.Hidi.Tests\check-nuget-package-published.Tests.ps1' + $configuration.Run.Path = @('test\Microsoft.OpenApi.Hidi.Tests\check-nuget-package-published.Tests.ps1', 'test\Microsoft.OpenApi.Hidi.Tests\verify-hidi-package-assembly.Tests.ps1') $configuration.Run.PassThru = $true $configuration.CodeCoverage.Enabled = $true - $configuration.CodeCoverage.Path = $helper + $configuration.CodeCoverage.Path = $helpers $configuration.CodeCoverage.OutputFormat = 'JaCoCo' $configuration.CodeCoverage.OutputPath = Join-Path $outputRoot 'pester-coverage.xml' $configuration.CodeCoverage.CoveragePercentTarget = 80 $result = Invoke-Pester -Configuration $configuration - if ($result.FailedCount -gt 0 -or $result.PassedCount -lt 24) { - throw "Hidi NuGet helper tests failed or did not execute all 24 cases." + if ($result.FailedCount -gt 0 -or $result.PassedCount -lt 45) { + throw "Hidi NuGet helper tests failed or did not execute all 45 cases." } $report = [xml](Get-Content $configuration.CodeCoverage.OutputPath.Value -Raw) $sourceFiles = @($report.SelectNodes('//sourcefile')) - if ($sourceFiles.Count -ne 1 -or $sourceFiles[0].name -ne [IO.Path]::GetFileName($helper)) { - throw 'Expected measured coverage of only the Hidi NuGet helper.' - } - $lines = @($sourceFiles[0].SelectNodes('line')) - $sourceLineCount = @(Get-Content $helper).Count - $lineNumbers = @($lines | ForEach-Object { [int]$_.nr }) - if ($lines.Count -eq 0 -or @($lineNumbers | Select-Object -Unique).Count -ne $lines.Count -or - @($lines | Where-Object { - [int]$_.nr -le 0 -or [int]$_.nr -gt $sourceLineCount -or - -not $_.HasAttribute('ci') -or -not $_.HasAttribute('mi') -or - [int]$_.ci -lt 0 -or [int]$_.mi -lt 0 -or ([int]$_.ci + [int]$_.mi) -le 0 - }).Count -gt 0) { - throw 'Missing or invalid measured helper coverage lines.' - } - $covered = @($lines | Where-Object { [int]$_.ci -gt 0 }).Count - if ($covered / $lines.Count -lt 0.8) { - throw "Insufficient measured Hidi helper coverage: $covered/$($lines.Count) lines." + if ($sourceFiles.Count -ne $helpers.Count) { + throw 'Expected measured coverage of both Hidi NuGet helpers.' } $coverage = [xml]'' - $file = $coverage.CreateElement('file') - $file.SetAttribute('path', $helperPath) - [void]$coverage.DocumentElement.AppendChild($file) - foreach ($line in $lines) { - $entry = $coverage.CreateElement('lineToCover') - $entry.SetAttribute('lineNumber', $line.nr) - $entry.SetAttribute('covered', ([int]$line.ci -gt 0).ToString().ToLowerInvariant()) - [void]$file.AppendChild($entry) + foreach ($helperPath in $helperPaths) { + $helper = (Resolve-Path $helperPath).Path + $source = @($sourceFiles | Where-Object { $_.name -eq [IO.Path]::GetFileName($helper) }) + if ($source.Count -ne 1) { throw "Missing or ambiguous measured coverage for $helperPath." } + $lines = @($source[0].SelectNodes('line')) + $sourceLineCount = @(Get-Content $helper).Count + $lineNumbers = @($lines | ForEach-Object { [int]$_.nr }) + if ($lines.Count -eq 0 -or @($lineNumbers | Select-Object -Unique).Count -ne $lines.Count -or + @($lines | Where-Object { + [int]$_.nr -le 0 -or [int]$_.nr -gt $sourceLineCount -or + -not $_.HasAttribute('ci') -or -not $_.HasAttribute('mi') -or + [int]$_.ci -lt 0 -or [int]$_.mi -lt 0 -or ([int]$_.ci + [int]$_.mi) -le 0 + }).Count -gt 0) { + throw "Missing or invalid measured helper coverage lines for $helperPath." + } + $covered = @($lines | Where-Object { [int]$_.ci -gt 0 }).Count + if ($covered / $lines.Count -lt 0.8) { + throw "Insufficient measured Hidi helper coverage for ${helperPath}: $covered/$($lines.Count) lines." + } + $file = $coverage.CreateElement('file') + $file.SetAttribute('path', $helperPath) + [void]$coverage.DocumentElement.AppendChild($file) + foreach ($line in $lines) { + $entry = $coverage.CreateElement('lineToCover') + $entry.SetAttribute('lineNumber', $line.nr) + $entry.SetAttribute('covered', ([int]$line.ci -gt 0).ToString().ToLowerInvariant()) + [void]$file.AppendChild($entry) + } + Write-Host "Measured Hidi NuGet helper coverage for ${helperPath}: $covered/$($lines.Count) lines." } $coverage.Save((Join-Path $outputRoot 'sonar-coverage.xml')) - Write-Host "Measured Hidi NuGet helper coverage: $covered/$($lines.Count) lines; Sonar generic report generated." + Write-Host "Sonar generic report generated from measured coverage." - name: Build and analyze env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any diff --git a/README.md b/README.md index a73ea9b1..c8fd6df7 100644 --- a/README.md +++ b/README.md @@ -116,12 +116,12 @@ feed responses fail closed. Releases must use an exact `hidi-v2.` tag matching the project version and be newer than the `2.12.2` migration baseline. This pipeline implementation does not enable publishing or authorize resources. -The SonarCloud workflow runs the private-feed helper tests with Pester 5.7.1 and -converts measured JaCoCo line hits to Sonar generic coverage, alongside the -existing C# OpenCover reports. The Pester script resides inside the Hidi test +The SonarCloud workflow runs the private-feed and signed-payload helper tests +with Pester 5.7.1 and converts measured JaCoCo line hits to Sonar generic coverage, +alongside existing C# OpenCover reports. The Pester scripts reside inside the Hidi test project directory so Sonar assigns it to test sources; a linked sibling file does not establish that ownership. Run the helper tests locally with -`Import-Module Pester -RequiredVersion 5.7.1; Invoke-Pester .\test\Microsoft.OpenApi.Hidi.Tests\check-nuget-package-published.Tests.ps1`. +`Import-Module Pester -RequiredVersion 5.7.1; Invoke-Pester .\test\Microsoft.OpenApi.Hidi.Tests\*.Tests.ps1`. --- diff --git a/scripts/verify-hidi-package-assembly.ps1 b/scripts/verify-hidi-package-assembly.ps1 new file mode 100644 index 00000000..53365fe7 --- /dev/null +++ b/scripts/verify-hidi-package-assembly.ps1 @@ -0,0 +1,59 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +<# +.SYNOPSIS +Verifies the Hidi tool package contains the exact Microsoft-signed staging DLL. +.DESCRIPTION +Run after tool packing and before NuGet signing. A signed NuGet container does +not prove that its assembly payload retained the ESRP signature. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$PackagePath, + [Parameter(Mandatory = $true)] + [string]$SignedAssemblyPath +) + +$ErrorActionPreference = 'Stop' + +foreach ($path in @($PackagePath, $SignedAssemblyPath)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Missing Hidi signing verification input: $path" + } +} + +$temporaryDirectory = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) +New-Item -ItemType Directory -Path $temporaryDirectory | Out-Null +$packagedAssembly = Join-Path $temporaryDirectory 'Microsoft.OpenApi.Hidi.dll' +$archive = $null +try { + $archive = [IO.Compression.ZipFile]::OpenRead((Resolve-Path -LiteralPath $PackagePath).Path) + $assemblies = @($archive.Entries | Where-Object { $_.Name -ieq 'Microsoft.OpenApi.Hidi.dll' }) + if ($assemblies.Count -ne 1 -or $assemblies[0].FullName -cne 'tools/net8.0/any/Microsoft.OpenApi.Hidi.dll') { + throw 'Expected exactly one Hidi DLL at tools/net8.0/any/Microsoft.OpenApi.Hidi.dll.' + } + [IO.Compression.ZipFileExtensions]::ExtractToFile($assemblies[0], $packagedAssembly) + + $stagingHash = (Get-FileHash -LiteralPath $SignedAssemblyPath -Algorithm SHA256).Hash + $packageHash = (Get-FileHash -LiteralPath $packagedAssembly -Algorithm SHA256).Hash + if ($packageHash -cne $stagingHash) { + throw "Packaged Hidi DLL differs from the signed staging DLL: $packageHash != $stagingHash" + } + foreach ($assembly in @($SignedAssemblyPath, $packagedAssembly)) { + $signature = Get-AuthenticodeSignature -LiteralPath $assembly + if ($signature.Status -ne 'Valid' -or + $signature.SignerCertificate.Subject -notmatch '(^|,\s*)O=Microsoft Corporation(,|$)') { + throw "Hidi DLL must have a valid Microsoft Corporation Authenticode signature: $assembly ($($signature.Status))" + } + } + Write-Host "Verified packaged Hidi DLL: valid Microsoft Authenticode signature; signed staging SHA256=$stagingHash" +} +finally { + if ($null -ne $archive) { $archive.Dispose() } + if (Test-Path -LiteralPath $packagedAssembly) { + Remove-Item -LiteralPath $packagedAssembly -Force + } + Remove-Item -LiteralPath $temporaryDirectory -Force +} diff --git a/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj b/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj index b1cf4efa..ad64f08b 100644 --- a/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj +++ b/src/Microsoft.OpenApi.Hidi/Microsoft.OpenApi.Hidi.csproj @@ -68,4 +68,25 @@ + + + + + <_HidiAssemblyToReplace Include="@(ResolvedFileToPublish)" + Condition="'%(ResolvedFileToPublish.RelativePath)' == '$(TargetFileName)'"/> + + + + + + $(TargetFileName) + Always + + + + diff --git a/src/Microsoft.OpenApi.Hidi/readme.md b/src/Microsoft.OpenApi.Hidi/readme.md index a922a5ee..56282a8b 100644 --- a/src/Microsoft.OpenApi.Hidi/readme.md +++ b/src/Microsoft.OpenApi.Hidi/readme.md @@ -72,6 +72,21 @@ Local/CI builds use the public-only strong-name identity. Official release artifacts are signed in Azure Pipelines; private signing keys do not belong in this repository. The migration baseline 2.12.2 is already published. Destination NuGet, executable, and Docker publishing are disabled until source cutover. +Official tool packing passes `HidiSignedAssemblyPath` to replace the Hidi DLL +in the SDK's `ResolvedFileToPublish` items after `ComputeFilesToPublish`. +`PackAsTool` publishes the intermediate assembly from `obj`, so replacing only +the DLL in `bin` does not preserve its Authenticode signature. The opt-in target +uses the exact ESRP staging DLL without recompiling it; ordinary local packing +and Windows single-file publishing retain their default inputs. +Before NuGet signing, `scripts/verify-hidi-package-assembly.ps1` requires exactly +one DLL at `tools/net8.0/any/Microsoft.OpenApi.Hidi.dll`, verifies its SHA256 +matches the staging DLL, and requires valid Microsoft Corporation Authenticode +signatures on both. Missing, mismatched, unsigned, or unverifiable payloads fail +the build. A signed NuGet container alone is not assembly-signature evidence. +Local byte-provenance checks and mocked signature unit tests cannot establish +Microsoft ESRP signing readiness; that requires a publish-disabled official run +after the normally approved merge. + The standard Release Please config tracks Hidi as its own component, with a separate manifest version, project version, changelog, and `hidi-v2.*` tags. The first destination package release must advance beyond the baseline and use diff --git a/test/Microsoft.OpenApi.Hidi.Tests/verify-hidi-package-assembly.Tests.ps1 b/test/Microsoft.OpenApi.Hidi.Tests/verify-hidi-package-assembly.Tests.ps1 new file mode 100644 index 00000000..4dd2a0e7 --- /dev/null +++ b/test/Microsoft.OpenApi.Hidi.Tests/verify-hidi-package-assembly.Tests.ps1 @@ -0,0 +1,90 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +BeforeDiscovery { + $cases = @( + @{ Name = 'matching signed DLL before NuGet signing' } + @{ Name = 'different payload bytes'; Payload = 'unsigned-build'; ExpectedError = '*differs from the signed staging DLL*' } + @{ Name = 'missing tool DLL'; Entries = @(); ExpectedError = '*exactly one Hidi DLL*' } + @{ Name = 'wrong tool location'; Entries = @('lib/net8.0/Microsoft.OpenApi.Hidi.dll'); ExpectedError = '*exactly one Hidi DLL*' } + @{ Name = 'duplicate tool DLL'; Entries = @('tools/net8.0/any/Microsoft.OpenApi.Hidi.dll', 'tools/net8.0/any/Microsoft.OpenApi.Hidi.dll'); ExpectedError = '*exactly one Hidi DLL*' } + @{ Name = 'extra tool DLL elsewhere'; Entries = @('tools/net8.0/any/Microsoft.OpenApi.Hidi.dll', 'lib/net8.0/Microsoft.OpenApi.Hidi.dll'); ExpectedError = '*exactly one Hidi DLL*' } + @{ Name = 'wrong entry casing'; Entries = @('tools/net8.0/any/microsoft.openapi.hidi.dll'); ExpectedError = '*exactly one Hidi DLL*' } + @{ Name = 'unsigned package payload'; PackageStatus = 'NotSigned'; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'damaged package signature'; PackageStatus = 'HashMismatch'; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'unverifiable package signature'; PackageStatus = 'UnknownError'; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'unsigned staging DLL'; StagingStatus = 'NotSigned'; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'damaged staging signature'; StagingStatus = 'HashMismatch'; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'non-Microsoft payload signer'; PackageSubject = 'CN=Example, O=Example'; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'non-Microsoft staging signer'; StagingSubject = 'CN=Example, O=Example'; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'misleading Microsoft signer name'; PackageSubject = 'CN=Microsoft Corporation, O=Example'; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'missing payload certificate'; MissingPackageCertificate = $true; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'missing staging certificate'; MissingStagingCertificate = $true; ExpectedError = '*valid Microsoft Corporation Authenticode signature*' } + @{ Name = 'missing package'; MissingPackage = $true; ExpectedError = '*Missing Hidi signing verification input*' } + @{ Name = 'missing staging DLL'; MissingStaging = $true; ExpectedError = '*Missing Hidi signing verification input*' } + @{ Name = 'invalid package archive'; InvalidArchive = $true; ExpectedError = '*' } + @{ Name = 'signature verification error'; SignatureError = $true; ExpectedError = '*Signature verification unavailable*' } + ) +} + +Describe 'Hidi package signed-assembly provenance' { + BeforeAll { + $helper = Join-Path $PSScriptRoot '..\..\scripts\verify-hidi-package-assembly.ps1' + } + + It '' -ForEach $cases { + $testCase = $_ + $directory = Join-Path $TestDrive ([guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $directory | Out-Null + $package = Join-Path $directory 'Microsoft.OpenApi.Hidi.2.13.0.nupkg' + $staging = Join-Path $directory 'Microsoft.OpenApi.Hidi.dll' + if (-not $MissingStaging) { [IO.File]::WriteAllText($staging, 'signed-staging') } + if (-not $MissingPackage) { + if ($InvalidArchive) { + [IO.File]::WriteAllText($package, 'not-a-zip') + } + else { + $archive = [IO.Compression.ZipFile]::Open($package, 'Create') + try { + $entryNames = if ($testCase.ContainsKey('Entries')) { $Entries } else { @('tools/net8.0/any/Microsoft.OpenApi.Hidi.dll') } + foreach ($name in $entryNames) { + $writer = [IO.StreamWriter]::new($archive.CreateEntry($name).Open()) + try { $writer.Write($(if ($Payload) { $Payload } else { 'signed-staging' })) } + finally { $writer.Dispose() } + } + } + finally { $archive.Dispose() } + } + } + $inspectedPaths = [Collections.Generic.List[string]]::new() + Mock Get-AuthenticodeSignature { + param($LiteralPath) + $inspectedPaths.Add($LiteralPath) + if ($SignatureError) { throw 'Signature verification unavailable' } + $isStaging = $LiteralPath -eq $staging + $status = if ($isStaging -and $StagingStatus) { $StagingStatus } + elseif (-not $isStaging -and $PackageStatus) { $PackageStatus } + else { 'Valid' } + $subject = if ($isStaging -and $StagingSubject) { $StagingSubject } + elseif (-not $isStaging -and $PackageSubject) { $PackageSubject } + else { 'CN=Microsoft Corporation, O=Microsoft Corporation, C=US' } + $certificate = if (($isStaging -and $MissingStagingCertificate) -or + (-not $isStaging -and $MissingPackageCertificate)) { $null } + else { [pscustomobject]@{ Subject = $subject } } + [pscustomobject]@{ Status = $status; SignerCertificate = $certificate } + } + + if ($ExpectedError) { + { & $helper -PackagePath $package -SignedAssemblyPath $staging } | Should -Throw $ExpectedError + } + else { + $output = & $helper -PackagePath $package -SignedAssemblyPath $staging 6>&1 + $output | Should -Match "signed staging SHA256=$((Get-FileHash -LiteralPath $staging).Hash)" + Should -Invoke Get-AuthenticodeSignature -Times 2 -Exactly + } + foreach ($path in $inspectedPaths | Where-Object { $_ -ne $staging }) { + Test-Path -LiteralPath $path | Should -BeFalse + Test-Path -LiteralPath (Split-Path $path -Parent) | Should -BeFalse + } + } +}