From cc1ada9a292ffeebd3a27feb9331dec70ac61263 Mon Sep 17 00:00:00 2001 From: Bill Berry Date: Fri, 4 Sep 2026 18:50:00 -0700 Subject: [PATCH] fix(security): bind release provenance to source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔒 - Generated by Copilot --- .github/workflows/release-vsix-publish.yml | 26 +++++-- SECURITY.md | 21 ++++-- docs/security/sbom-verification.md | 7 +- docs/security/vex-verification.md | 11 ++- .../release/Invoke-ProvenanceVerification.ps1 | 40 ++++++++++- .../Workflow-PackagingContracts.Tests.ps1 | 10 ++- .../Invoke-ProvenanceVerification.Tests.ps1 | 69 +++++++++++++++++-- 7 files changed, 162 insertions(+), 22 deletions(-) diff --git a/.github/workflows/release-vsix-publish.yml b/.github/workflows/release-vsix-publish.yml index 24beec9043..e0714e3dfc 100644 --- a/.github/workflows/release-vsix-publish.yml +++ b/.github/workflows/release-vsix-publish.yml @@ -493,12 +493,20 @@ jobs: ## Verifying Downloads - All release assets include [Sigstore](https://www.sigstore.dev/) build-provenance - attestations generated by GitHub Actions. Verify any downloaded asset with the - GitHub CLI: + The VSIX and OpenVEX document are build-provenance subjects. The SBOMs are + predicate payloads, the `.sigstore.json` and `.intoto.jsonl` files carry the + VSIX verification material, and `dependency-diff.md` is informational. + + Resolve the expected source from the exact Stable tag before verifying subjects: ```bash - gh attestation verify .vsix --repo microsoft/hve-core + TAG='v' + SOURCE_SHA=$(gh api "repos/microsoft/hve-core/commits/$TAG" --jq '.sha') + + gh attestation verify .vsix --repo microsoft/hve-core \ + --signer-workflow microsoft/hve-core/.github/workflows/extension-provenance-signer.yml \ + --signer-digest 3a09401536cef0c4559db1aa64b7d1010638fd67 \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" ``` ## VEX (Vulnerability Exploitability eXchange) @@ -510,8 +518,14 @@ jobs: populated, use it alongside the SBOM to filter false positives in vulnerability scanners: ```bash - # Verify VEX attestation - gh attestation verify hve-core.openvex.json --repo microsoft/hve-core + gh attestation verify hve-core.openvex.json --repo microsoft/hve-core \ + --signer-workflow microsoft/hve-core/.github/workflows/vex-attest.yml \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" + + gh attestation verify dependencies.spdx.json --repo microsoft/hve-core \ + --signer-workflow microsoft/hve-core/.github/workflows/vex-attest.yml \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" \ + --predicate-type https://openvex.dev/ns/v0.2.0 ``` See [SECURITY.md](https://github.com/microsoft/hve-core/blob/main/SECURITY.md) diff --git a/SECURITY.md b/SECURITY.md index 3e2f1f8c6f..e18386fe42 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -102,14 +102,20 @@ HVE Core publishes cryptographically attested assets under exact channel tags: ```bash # Stable VSIX + TAG='v' + SOURCE_SHA=$(gh api "repos/microsoft/hve-core/commits/$TAG" --jq '.sha') gh attestation verify hve-core-.vsix -R microsoft/hve-core \ - --signer-workflow microsoft/hve-core/.github/workflows/extension-provenance-signer.yml \ - --signer-digest 3a09401536cef0c4559db1aa64b7d1010638fd67 + --signer-workflow microsoft/hve-core/.github/workflows/extension-provenance-signer.yml \ + --signer-digest 3a09401536cef0c4559db1aa64b7d1010638fd67 \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" # PreRelease VSIX + TAG='prerelease-v' + SOURCE_SHA=$(gh api "repos/microsoft/hve-core/commits/$TAG" --jq '.sha') gh attestation verify hve-core-.vsix -R microsoft/hve-core \ - --signer-workflow microsoft/hve-core/.github/workflows/extension-provenance-signer.yml \ - --signer-digest 3a09401536cef0c4559db1aa64b7d1010638fd67 + --signer-workflow microsoft/hve-core/.github/workflows/extension-provenance-signer.yml \ + --signer-digest 3a09401536cef0c4559db1aa64b7d1010638fd67 \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" ``` The GitHub Release is the canonical verification surface for SLSA and Sigstore @@ -182,11 +188,16 @@ build provenance for the OpenVEX file as a subject, and an OpenVEX predicate over `dependencies.spdx.json` as a subject. ```bash +TAG='v' +SOURCE_SHA=$(gh api "repos/microsoft/hve-core/commits/$TAG" --jq '.sha') + gh attestation verify hve-core.openvex.json -R microsoft/hve-core \ - --signer-workflow microsoft/hve-core/.github/workflows/vex-attest.yml + --signer-workflow microsoft/hve-core/.github/workflows/vex-attest.yml \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" gh attestation verify dependencies.spdx.json -R microsoft/hve-core \ --signer-workflow microsoft/hve-core/.github/workflows/vex-attest.yml \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" \ --predicate-type https://openvex.dev/ns/v0.2.0 ``` diff --git a/docs/security/sbom-verification.md b/docs/security/sbom-verification.md index dffb027747..d0e917ceb1 100644 --- a/docs/security/sbom-verification.md +++ b/docs/security/sbom-verification.md @@ -65,17 +65,22 @@ Verify SPDX predicates through their primary artifact subjects: ```bash # Stable VSIX +TAG='v' +SOURCE_SHA=$(gh api "repos/microsoft/hve-core/commits/$TAG" --jq '.sha') gh attestation verify hve-core-.vsix -R microsoft/hve-core \ --signer-workflow microsoft/hve-core/.github/workflows/extension-provenance-signer.yml \ --signer-digest 3a09401536cef0c4559db1aa64b7d1010638fd67 \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" \ --predicate-type https://spdx.dev/Document/v2.3 # PreRelease VSIX +TAG='prerelease-v' +SOURCE_SHA=$(gh api "repos/microsoft/hve-core/commits/$TAG" --jq '.sha') gh attestation verify hve-core-.vsix -R microsoft/hve-core \ --signer-workflow microsoft/hve-core/.github/workflows/extension-provenance-signer.yml \ --signer-digest 3a09401536cef0c4559db1aa64b7d1010638fd67 \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" \ --predicate-type https://spdx.dev/Document/v2.3 - ``` These commands can match both the per-artifact and dependency SBOM diff --git a/docs/security/vex-verification.md b/docs/security/vex-verification.md index c5c1dd1edb..919c8015b0 100644 --- a/docs/security/vex-verification.md +++ b/docs/security/vex-verification.md @@ -3,7 +3,7 @@ title: VEX Verification description: Download, verify, and interpret the OpenVEX vulnerability exploitability document published with Stable HVE Core releases sidebar_position: 4 author: Microsoft -ms.date: 2026-08-09 +ms.date: 2026-09-04 ms.topic: how-to keywords: - VEX @@ -63,11 +63,17 @@ gh release download v -R microsoft/hve-core \ A Stable release publishes two Sigstore attestations for the VEX document: +```bash +TAG='v' +SOURCE_SHA=$(gh api "repos/microsoft/hve-core/commits/$TAG" --jq '.sha') +``` + 1. **Provenance of the VEX document**, so you can confirm the file itself came from the official pipeline. Verify it against the dedicated reusable VEX attestation workflow: ```bash gh attestation verify hve-core.openvex.json -R microsoft/hve-core \ - --signer-workflow microsoft/hve-core/.github/workflows/vex-attest.yml + --signer-workflow microsoft/hve-core/.github/workflows/vex-attest.yml \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" ``` 2. **VEX bound to the dependency SBOM**, where the VEX document is the in-toto *predicate* over the SBOM *subject* (the OpenVEX "encapsulating format"). This lets VEX-aware tooling resolve the exploitability assessment for the product's component inventory: @@ -75,6 +81,7 @@ A Stable release publishes two Sigstore attestations for the VEX document: ```bash gh attestation verify dependencies.spdx.json -R microsoft/hve-core \ --signer-workflow microsoft/hve-core/.github/workflows/vex-attest.yml \ + --source-digest "$SOURCE_SHA" --source-ref "refs/tags/$TAG" \ --predicate-type https://openvex.dev/ns/v0.2.0 ``` diff --git a/scripts/release/Invoke-ProvenanceVerification.ps1 b/scripts/release/Invoke-ProvenanceVerification.ps1 index dddffac39f..828a4e79ac 100644 --- a/scripts/release/Invoke-ProvenanceVerification.ps1 +++ b/scripts/release/Invoke-ProvenanceVerification.ps1 @@ -460,6 +460,21 @@ function Invoke-ProvenanceVerification { throw 'Provenance invariant failed: release artifacts must contain exactly one VSIX' } + $IsStable = $ReleaseTag.StartsWith('v', [System.StringComparison]::Ordinal) + $VexArtifacts = @($artifacts | Where-Object { $_.Name -ieq 'hve-core.openvex.json' }) + $DependencySbomArtifacts = @($artifacts | Where-Object { $_.Name -ieq 'dependencies.spdx.json' }) + if ($IsStable) { + if ($VexArtifacts.Count -ne 1 -or $VexArtifacts[0].Name -cne 'hve-core.openvex.json') { + throw 'Provenance invariant failed: Stable release artifacts must contain exactly one OpenVEX document' + } + if ($DependencySbomArtifacts.Count -ne 1 -or $DependencySbomArtifacts[0].Name -cne 'dependencies.spdx.json') { + throw 'Provenance invariant failed: Stable release artifacts must contain exactly one dependency SBOM' + } + } + elseif ($VexArtifacts.Count -ne 0) { + throw 'Provenance invariant failed: PreRelease artifacts must not contain an OpenVEX document' + } + $VsixArtifact = $VsixArtifacts[0] $SigstorePath = "$($VsixArtifact.FullName).sigstore.json" $IntotoPath = "$($VsixArtifact.FullName).intoto.jsonl" @@ -492,8 +507,29 @@ function Invoke-ProvenanceVerification { elseif ($artifact.Name -like '*.zip') { $null = Invoke-ExternalCommand -Command 'gh' -Arguments @('attestation', 'verify', $fullPath, '--repo', $Repository) } - elseif ($artifact.Name -eq 'hve-core.openvex.json') { - $null = Invoke-ExternalCommand -Command 'gh' -Arguments @('attestation', 'verify', $fullPath, '--repo', $Repository, '--signer-workflow', "$Repository/.github/workflows/vex-attest.yml", '--predicate-type', 'https://openvex.dev/ns/v0.2.0') + elseif ($artifact.Name -ceq 'hve-core.openvex.json') { + $null = Invoke-ExternalCommand -Command 'gh' -Arguments @( + 'attestation', 'verify', $fullPath, + '--repo', $Repository, + '--signer-workflow', "$Repository/.github/workflows/vex-attest.yml", + '--signer-digest', $ExpectedSourceSha, + '--source-digest', $ExpectedSourceSha, + '--source-ref', "refs/tags/$ReleaseTag", + '--predicate-type', 'https://slsa.dev/provenance/v1', + '--deny-self-hosted-runners' + ) + } + elseif ($IsStable -and $artifact.Name -ceq 'dependencies.spdx.json') { + $null = Invoke-ExternalCommand -Command 'gh' -Arguments @( + 'attestation', 'verify', $fullPath, + '--repo', $Repository, + '--signer-workflow', "$Repository/.github/workflows/vex-attest.yml", + '--signer-digest', $ExpectedSourceSha, + '--source-digest', $ExpectedSourceSha, + '--source-ref', "refs/tags/$ReleaseTag", + '--predicate-type', 'https://openvex.dev/ns/v0.2.0', + '--deny-self-hosted-runners' + ) } } } diff --git a/scripts/tests/extension/Workflow-PackagingContracts.Tests.ps1 b/scripts/tests/extension/Workflow-PackagingContracts.Tests.ps1 index ba45d4e864..d29147810c 100644 --- a/scripts/tests/extension/Workflow-PackagingContracts.Tests.ps1 +++ b/scripts/tests/extension/Workflow-PackagingContracts.Tests.ps1 @@ -1537,11 +1537,19 @@ Describe 'Retained release reconciliation and OpenVEX' -Tag 'Unit', 'ReleaseReco [string[]]@($document['jobs']['publish-release']['needs']) | Should -Contain 'vex-attest' } - It 'Documents VSIX and VEX verification without a plugin ZIP instruction' { + It 'Documents source-bound subject-specific verification without a plugin ZIP instruction' { $notes = [string](Get-NamedJobStep -Document (Get-WorkflowDocument -Name 'release-vsix-publish.yml') ` -JobName 'append-verification-notes' -StepName 'Append verification section to release notes')['run'] $notes | Should -Match 'gh attestation verify \.vsix' $notes | Should -Match 'gh attestation verify hve-core\.openvex\.json' + $notes | Should -Match 'gh attestation verify dependencies\.spdx\.json' + $notes | Should -Match 'SOURCE_SHA=\$\(gh api' + @([regex]::Matches($notes, '--source-digest')) | Should -HaveCount 3 + @([regex]::Matches($notes, '--source-ref')) | Should -HaveCount 3 + $notes | Should -Match 'extension-provenance-signer\.yml' + $notes | Should -Match '3a09401536cef0c4559db1aa64b7d1010638fd67' + $notes | Should -Match 'SBOMs are\s+predicate payloads' + $notes | Should -Not -Match 'All release assets include.*build-provenance' $notes | Should -Not -Match '\.zip' } diff --git a/scripts/tests/release/Invoke-ProvenanceVerification.Tests.ps1 b/scripts/tests/release/Invoke-ProvenanceVerification.Tests.ps1 index 2651689bcc..207468091e 100644 --- a/scripts/tests/release/Invoke-ProvenanceVerification.Tests.ps1 +++ b/scripts/tests/release/Invoke-ProvenanceVerification.Tests.ps1 @@ -8,7 +8,7 @@ BeforeAll { $script:Repository = 'microsoft/hve-core' $script:SourceSha = '0123456789abcdef0123456789abcdef01234567' $script:SignerSha = 'abcdef0123456789abcdef0123456789abcdef01' - $script:ReleaseTag = 'v3.4.0' + $script:ReleaseTag = 'prerelease-v3.3.0' function New-VerifiedResultFixture { <# @@ -354,26 +354,85 @@ Describe 'Invoke-ProvenanceVerification' -Tag 'Unit' { } | Should -Throw $Error } - It 'Retains ZIP and OpenVEX cryptographic verification without parsing sidecars' { + It 'Retains ZIP verification without parsing unrelated metadata' { $ZipPath = Join-Path $script:tempDir 'hve-core-plugin.zip' - $VexPath = Join-Path $script:tempDir 'hve-core.openvex.json' $UnrelatedPath = Join-Path $script:tempDir 'unrelated.metadata.json' Set-Content -LiteralPath $ZipPath -Value 'zip bytes' - Set-Content -LiteralPath $VexPath -Value '{"sanitized":true}' Set-Content -LiteralPath $UnrelatedPath -Value '{not-trusted}' { Invoke-ProvenanceVerification -ArtifactDirectory $script:tempDir -Repository $script:Repository -ExpectedSourceSha $script:SourceSha -ExpectedSignerSha $script:SignerSha -ReleaseTag $script:ReleaseTag } | Should -Not -Throw - Should -Invoke Invoke-ExternalCommand -Times 3 -Exactly + Should -Invoke Invoke-ExternalCommand -Times 2 -Exactly Should -Invoke Invoke-ExternalCommand -Times 1 -Exactly -ParameterFilter { $Command -eq 'gh' -and $Arguments[2] -eq [System.IO.Path]::GetFullPath($ZipPath) -and ($Arguments -join ' ') -eq "attestation verify $([System.IO.Path]::GetFullPath($ZipPath)) --repo $script:Repository" } + } + + It 'Rejects an OpenVEX document in a PreRelease artifact set' { + Set-Content -LiteralPath (Join-Path $script:tempDir 'hve-core.openvex.json') -Value '{"sanitized":true}' + + { + Invoke-ProvenanceVerification -ArtifactDirectory $script:tempDir -Repository $script:Repository -ExpectedSourceSha $script:SourceSha -ExpectedSignerSha $script:SignerSha -ReleaseTag $script:ReleaseTag + } | Should -Throw '*PreRelease artifacts must not contain an OpenVEX document*' + Should -Invoke Invoke-ExternalCommand -Times 0 -Exactly + } + + It 'Rejects a Stable artifact set missing ' -ForEach @( + @{ Missing = 'OpenVEX document'; Add = 'dependencies.spdx.json' } + @{ Missing = 'dependency SBOM'; Add = 'hve-core.openvex.json' } + ) { + Set-Content -LiteralPath (Join-Path $script:tempDir $Add) -Value '{"sanitized":true}' + + { + Invoke-ProvenanceVerification -ArtifactDirectory $script:tempDir -Repository $script:Repository -ExpectedSourceSha $script:SourceSha -ExpectedSignerSha $script:SignerSha -ReleaseTag 'v3.4.0' + } | Should -Throw "*$Missing*" + Should -Invoke Invoke-ExternalCommand -Times 0 -Exactly + } + + It 'Rejects a Stable artifact set with a duplicate or noncanonical ' -ForEach @( + @{ Subject = 'OpenVEX document'; Canonical = 'hve-core.openvex.json'; Variant = 'HVE-Core.OpenVEX.json' } + @{ Subject = 'dependency SBOM'; Canonical = 'dependencies.spdx.json'; Variant = 'Dependencies.SPDX.json' } + ) { + Set-Content -LiteralPath (Join-Path $script:tempDir 'hve-core.openvex.json') -Value '{"sanitized":true}' + Set-Content -LiteralPath (Join-Path $script:tempDir 'dependencies.spdx.json') -Value '{"spdxVersion":"SPDX-2.3"}' + Rename-Item -LiteralPath (Join-Path $script:tempDir $Canonical) -NewName $Variant + + { + Invoke-ProvenanceVerification -ArtifactDirectory $script:tempDir -Repository $script:Repository -ExpectedSourceSha $script:SourceSha -ExpectedSignerSha $script:SignerSha -ReleaseTag 'v3.4.0' + } | Should -Throw "*exactly one $Subject*" + Should -Invoke Invoke-ExternalCommand -Times 0 -Exactly + } + + It 'Verifies Stable VEX provenance and OpenVEX over the dependency SBOM separately' { + $VexPath = Join-Path $script:tempDir 'hve-core.openvex.json' + $DependencySbomPath = Join-Path $script:tempDir 'dependencies.spdx.json' + Set-Content -LiteralPath $VexPath -Value '{"sanitized":true}' + Set-Content -LiteralPath $DependencySbomPath -Value '{"spdxVersion":"SPDX-2.3"}' + + $script:ReleaseTag = 'v3.4.0' + $script:Fixture = New-VerifiedResultFixture -VsixPath $script:vsixPath + $script:VerificationJson = ConvertTo-Json -InputObject @($script:Fixture) -Depth 30 -Compress + $script:Fixture.attestation | ConvertTo-Json -Depth 30 -Compress | + Set-Content -LiteralPath $script:SigstorePath -Encoding utf8NoBOM + $script:Fixture.attestation.dsseEnvelope | ConvertTo-Json -Depth 30 -Compress | + Set-Content -LiteralPath $script:IntotoPath -Encoding utf8NoBOM + + Invoke-ProvenanceVerification -ArtifactDirectory $script:tempDir -Repository $script:Repository -ExpectedSourceSha $script:SourceSha -ExpectedSignerSha $script:SignerSha -ReleaseTag $script:ReleaseTag + + Should -Invoke Invoke-ExternalCommand -Times 3 -Exactly Should -Invoke Invoke-ExternalCommand -Times 1 -Exactly -ParameterFilter { $Command -eq 'gh' -and $Arguments[2] -eq [System.IO.Path]::GetFullPath($VexPath) -and $Arguments -contains "$script:Repository/.github/workflows/vex-attest.yml" -and + $Arguments -contains 'https://slsa.dev/provenance/v1' -and + $Arguments -contains $script:SourceSha -and + $Arguments -contains "refs/tags/$script:ReleaseTag" + } + Should -Invoke Invoke-ExternalCommand -Times 1 -Exactly -ParameterFilter { + $Command -eq 'gh' -and $Arguments[2] -eq [System.IO.Path]::GetFullPath($DependencySbomPath) -and + $Arguments -contains "$script:Repository/.github/workflows/vex-attest.yml" -and $Arguments -contains 'https://openvex.dev/ns/v0.2.0' } }