Skip to content

Commit c2542d4

Browse files
Reject projected provenance and inconsistent release archive claims
Validate raw producing context and proof shapes before projection. Verify ZIP bytes and payload as one snapshot against the tested tree before claims, Gallery writes, release creation and upload. (cherry picked from commit e59ac33)
1 parent 85003e1 commit c2542d4

7 files changed

Lines changed: 250 additions & 15 deletions

File tree

‎.github/actions/Publish-PSModule/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ An observed release tag must remain unchanged through shared coordinate creation
88

99
An existing Gallery package is retained only when its raw nupkg payload matches that proof and the tested artifact. Package code is never imported to establish ownership. Unknown legacy proof, foreign tags/releases/assets, changed claims, authentication errors, and API failures stop publication explicitly.
1010

11+
Provenance must contain one original JSON object with scalar proof fields; arrays are not projected into valid identities. The ZIP's exact bytes and canonical payload digest are verified together against the tested file tree before a claim or Gallery write. Original event PR context is validated before normalized fields are selected.
12+
1113
## Inputs
1214

1315
| Name | Description | Required | Default |

‎.github/actions/Publish-PSModule/src/publish.ps1‎

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,15 @@ LogGroup 'Load release context' {
7272
$pullRequest = ConvertFrom-Json -InputObject $pullRequestJson -NoEnumerate -ErrorAction Stop
7373
} else {
7474
$githubEventJson = Get-Content -Raw $env:GITHUB_EVENT_PATH
75-
$githubEvent = $githubEventJson | ConvertFrom-Json
76-
if ($githubEvent.pull_request) {
75+
$githubEvent = ConvertFrom-Json -InputObject $githubEventJson -NoEnumerate -ErrorAction Stop
76+
if ($githubEvent -is [array] -or $githubEvent -isnot [PSCustomObject]) {
77+
throw 'The original GitHub event context requires one event object.'
78+
}
79+
$eventPullRequest = $githubEvent.pull_request
80+
$null = Get-PSModuleProducingPullRequestNumber -PullRequest $eventPullRequest
81+
if ($null -ne $eventPullRequest) {
7782
$pullRequest = [pscustomobject]@{
78-
Number = $githubEvent.pull_request.number
83+
Number = $eventPullRequest.number
7984
}
8085
}
8186
}
@@ -178,6 +183,11 @@ try {
178183
AssetName = $zipFileName
179184
AssetSha256 = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant()
180185
}
186+
$archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name
187+
if ($archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or
188+
$archiveProof.AssetSha256 -cne $claim.AssetSha256) {
189+
throw 'The release archive does not match the tested file-tree and asset claim. Refusing publication.'
190+
}
181191
$publicationReader = Get-PSModulePublicationReader -Repository $env:GITHUB_REPOSITORY
182192
$publicationState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader
183193
}
@@ -195,10 +205,19 @@ try {
195205
Write-Host "WhatIf: Create-only annotated claim for [$releaseTag]; retain any existing published bytes."
196206
Write-Host "Publish-PSResource -Path $modulePath -Repository PSGallery -ApiKey ***"
197207
} else {
208+
$archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name
209+
if ($archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or
210+
$archiveProof.AssetSha256 -cne $claim.AssetSha256 -or
211+
(Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) {
212+
throw 'The tested artifact or release archive changed before the immutable claim. Refusing publication.'
213+
}
198214
$publicationState = New-PSModulePublicationTag -Claim $claim -State $publicationState -Reader $publicationReader
199215
$currentState = Get-PSModulePublicationState -ExpectedClaim $claim -Reader $publicationReader
216+
$archiveProof = Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName $name
200217
if ($currentState.TagSha -cne $publicationState.TagSha -or
201218
$currentState.VersionTagSha -cne $publicationState.VersionTagSha -or
219+
$archiveProof.ArtifactSha256 -cne $claim.ArtifactSha256 -or
220+
$archiveProof.AssetSha256 -cne $claim.AssetSha256 -or
202221
(Get-PSModuleArtifactDigest -Path $modulePath) -cne $claim.ArtifactSha256) {
203222
throw 'The publication claim or tested artifact changed before the Gallery write. Refusing publication.'
204223
}

‎.github/actions/Publish-PSModule/tests/Publish-PSModule.Preflight.Tests.ps1‎

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ BeforeAll {
1818
$script:publishScriptPath = [IO.Path]::GetFullPath(
1919
[IO.Path]::Combine($PSScriptRoot, '..', 'src', 'publish.ps1')
2020
)
21+
$script:releaseScriptPath = [IO.Path]::GetFullPath(
22+
[IO.Path]::Combine($PSScriptRoot, '..', '..', 'Release-PSModule', 'src', 'release.ps1')
23+
)
2124
$script:environmentVariableNames = @(
2225
'GITHUB_EVENT_PATH'
26+
'GITHUB_OUTPUT'
2327
'GITHUB_REPOSITORY'
2428
'GITHUB_REPOSITORY_NAME'
2529
'GITHUB_SHA'
@@ -32,6 +36,16 @@ BeforeAll {
3236
'PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix'
3337
'PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha'
3438
'PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf'
39+
'PSMODULE_RELEASE_PSMODULE_INPUT_Name'
40+
'PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath'
41+
'PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag'
42+
'PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix'
43+
'PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha'
44+
'PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest'
45+
'PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf'
46+
'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes'
47+
'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName'
48+
'PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading'
3549
)
3650
$script:originalEnvironment = @{}
3751
foreach ($name in $script:environmentVariableNames) {
@@ -97,6 +111,18 @@ Describe 'Publish-PSModule source-bound preflight' {
97111
$env:PSMODULE_PUBLISH_PSMODULE_INPUT_VersionPrefix = 'v'
98112
$env:PSMODULE_PUBLISH_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA
99113
$env:PSMODULE_PUBLISH_PSMODULE_INPUT_WhatIf = 'false'
114+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_Name = 'TestModule'
115+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_ModulePath = '.PSModule/module'
116+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_ReleaseTag = 'v1.2.4'
117+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_VersionPrefix = 'v'
118+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_CommitSha = $env:GITHUB_SHA
119+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_PullRequest = ''
120+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_WhatIf = 'false'
121+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRBodyAsReleaseNotes = 'false'
122+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsReleaseName = 'false'
123+
$env:PSMODULE_RELEASE_PSMODULE_INPUT_UsePRTitleAsNotesHeading = 'false'
124+
$env:GITHUB_OUTPUT = Join-Path -Path $env:GITHUB_WORKSPACE -ChildPath 'outputs'
125+
$null = New-Item -Path $env:GITHUB_OUTPUT -ItemType File
100126

101127
$script:state = New-PSModulePublicationFixture -Path $script:modulePath -Version '1.2.4' -Tag 'v1.2.4'
102128
}
@@ -335,7 +361,10 @@ Describe 'Publish-PSModule source-bound preflight' {
335361
$script:state.GitHubWrites | Should -Be 0
336362
}
337363

338-
It 'rejects a ZIP payload inconsistent with the tested file-tree claim before all writes' {
364+
It 'rejects a <Controller> ZIP payload inconsistent with the tested file-tree claim before all writes' -ForEach @(
365+
@{ Controller = 'publisher' }
366+
@{ Controller = 'release' }
367+
) {
339368
Mock -CommandName Get-FileHash -ParameterFilter { $LiteralPath -like '*.zip' } -MockWith {
340369
$archive = [IO.Compression.ZipFile]::Open($LiteralPath, [IO.Compression.ZipArchiveMode]::Update)
341370
try {
@@ -357,15 +386,37 @@ Describe 'Publish-PSModule source-bound preflight' {
357386
}
358387
}
359388

389+
$controllerPath = $script:publishScriptPath
390+
if ($Controller -ceq 'release') { $controllerPath = $script:releaseScriptPath }
360391
$failure = $null
361-
try { & $script:publishScriptPath 1>$null 6>$null } catch { $failure = $_ }
392+
try { & $controllerPath 1>$null 6>$null } catch { $failure = $_ }
362393

363394
Should -Invoke -CommandName Get-FileHash -Times 1 -Exactly -ParameterFilter { $LiteralPath -like '*.zip' }
364395
$script:state.GalleryWrites | Should -Be 0
365396
$script:state.GitHubWrites | Should -Be 0
366397
$failure.Exception.Message | Should -Match 'archive'
367398
}
368399

400+
It 'rejects a changed <Controller> artifact before reserving any immutable claim' -ForEach @(
401+
@{ Controller = 'publisher' }
402+
@{ Controller = 'release' }
403+
) {
404+
$script:state.PayloadPath = Join-Path -Path $script:modulePath -ChildPath 'TestModule.psm1'
405+
$script:state.OnTagRead = {
406+
param($State)
407+
if ($State.TagReads -eq 1) {
408+
[IO.File]::WriteAllText($State.PayloadPath, '# Changed candidate after the byte snapshot.')
409+
}
410+
}
411+
$controllerPath = $script:publishScriptPath
412+
if ($Controller -ceq 'release') { $controllerPath = $script:releaseScriptPath }
413+
414+
{ & $controllerPath 1>$null 6>$null } | Should -Throw '*artifact*immutable claim*'
415+
416+
$script:state.GalleryWrites | Should -Be 0
417+
$script:state.GitHubWrites | Should -Be 0
418+
}
419+
369420
It 'rejects changed Gallery package bytes despite matching numeric version and source claim' {
370421
Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim
371422
$script:state.Package = [pscustomobject]@{ Name = 'TestModule'; Version = '1.2.4' }
@@ -566,6 +617,20 @@ Describe 'Publish-PSModule source-bound preflight' {
566617
$claim.PullRequest | Should -Be 42
567618
}
568619

620+
It 'rejects original release event PR context before projection' -ForEach @(
621+
@{ EventJson = '{"pull_request":[{"number":42}]}' }
622+
@{ EventJson = '{"pull_request":{"number":[42]}}' }
623+
@{ EventJson = '[{"pull_request":{"number":42}}]' }
624+
@{ EventJson = '{"pull_request":{}}' }
625+
) {
626+
[IO.File]::WriteAllText($env:GITHUB_EVENT_PATH, $EventJson)
627+
628+
{ & $script:releaseScriptPath 1>$null 6>$null } | Should -Throw '*context*'
629+
630+
$script:state.GalleryWrites | Should -Be 0
631+
$script:state.GitHubWrites | Should -Be 0
632+
}
633+
569634
It 'rejects a draft release before Gallery publication rather than reporting completed publication' {
570635
Set-PSModuleFixtureClaim -State $script:state -Claim $script:state.ExpectedClaim
571636
$script:state.Release = [pscustomobject]@{

‎.github/actions/Release-PSModule/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,6 @@ The action supports normalized pull-request context and direct stable releases.
4848

4949
Release creation and upload recheck the release tag, shared module/version coordinate, provenance, and artifact identity. Both refs must bind the same immutable claim, regardless of tag prefix. A missing upload can resume. An existing asset is retained only when its published SHA-256 matches the exact local ZIP; differing, ambiguous, or unverifiable bytes stop the action. Uploads never use `--clobber`, and tags are never retargeted.
5050

51+
The local ZIP's exact bytes and payload digest are verified together against the tested file tree before claiming, creating, or uploading. Original event PR context and versioned provenance must have valid object/scalar shapes before normalization; arrays cannot supply publication ownership.
52+
5153
Complete authored or generated prerelease notes precede a final repository/producing-PR/source footer. A matching tag/source does not authorize another PR's release. Legacy records without proof require reviewed forward-only recovery; the action does not fabricate provenance or alter consumer metadata.

‎.github/actions/Release-PSModule/src/Release-PSModule.Helpers.psm1‎

Lines changed: 101 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,9 +105,11 @@ function Get-PSModulePrereleaseOwnership {
105105
return
106106
}
107107
$bytes = [Convert]::FromBase64String($match.Groups[1].Value)
108-
$ownership = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop
108+
$ownership = ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)) -NoEnumerate -ErrorAction Stop
109109
$number = 0L
110-
if ([string]::IsNullOrWhiteSpace($ownership.Repository) -or
110+
if ($ownership -is [array] -or $ownership -isnot [PSCustomObject] -or
111+
$ownership.Repository -isnot [string] -or $ownership.CommitSha -isnot [string] -or
112+
$ownership.PullRequest -is [array] -or [string]::IsNullOrWhiteSpace($ownership.Repository) -or
111113
$ownership.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or
112114
-not [long]::TryParse([string]$ownership.PullRequest, [ref]$number) -or
113115
$number -lt 1 -or [string]::IsNullOrWhiteSpace($ownership.CommitSha)) {
@@ -155,6 +157,7 @@ function Get-PSModuleProducingPullRequestNumber {
155157
$number = 0L
156158
if ($PullRequest -is [array] -or
157159
($PullRequest -isnot [PSCustomObject] -and $PullRequest -isnot [Collections.IDictionary]) -or
160+
$PullRequest.Number -is [array] -or
158161
-not [long]::TryParse([string]$PullRequest.Number, [ref]$number) -or $number -lt 1) {
159162
throw 'Supplied pull request context requires a positive producing pull request number.'
160163
}
@@ -265,9 +268,21 @@ function Get-PSModulePublicationClaim {
265268
return
266269
}
267270
$bytes = [Convert]::FromBase64String($match.Groups[1].Value)
268-
$claim = [Text.Encoding]::UTF8.GetString($bytes) | ConvertFrom-Json -ErrorAction Stop
271+
$claim = ConvertFrom-Json -InputObject ([Text.Encoding]::UTF8.GetString($bytes)) -NoEnumerate -ErrorAction Stop
272+
if ($claim -is [array] -or $claim -isnot [PSCustomObject]) {
273+
throw 'The publication claim requires one proof object, not a projected JSON value.'
274+
}
275+
foreach ($field in @(
276+
'Repository', 'CommitSha', 'ModuleName', 'GalleryVersion', 'VersionPrefix',
277+
'ReleaseTag', 'ReleaseType', 'ArtifactSha256', 'AssetName', 'AssetSha256'
278+
)) {
279+
if ($claim.$field -isnot [string]) {
280+
throw "The publication claim requires a scalar string [$field]."
281+
}
282+
}
269283
$number = 0L
270284
if ($claim.Repository -notmatch '^[^/\s]+/[^/\s]+$' -or
285+
$claim.PullRequest -is [array] -or
271286
-not [long]::TryParse([string]$claim.PullRequest, [ref]$number) -or $number -lt 0 -or
272287
$claim.CommitSha -notmatch '^[0-9a-f]{40}$' -or
273288
$claim.ModuleName -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$' -or
@@ -623,6 +638,89 @@ function New-PSModuleReleaseArchive {
623638
[IO.FileInfo]::new($DestinationPath)
624639
}
625640

641+
function Get-PSModuleReleaseArchiveProof {
642+
<#
643+
.SYNOPSIS
644+
Read the release ZIP and its module payload as one byte-proof snapshot.
645+
646+
.DESCRIPTION
647+
Hashes the archive and exact payload records through the same open stream.
648+
The payload uses the canonical file-tree digest without extracting or importing files.
649+
650+
.EXAMPLE
651+
Get-PSModuleReleaseArchiveProof -Path $zipPath -ModuleName TestModule
652+
653+
Return the archive and payload digests to verify against the tested claim.
654+
655+
.INPUTS
656+
None
657+
658+
You can't pipe objects to Get-PSModuleReleaseArchiveProof.
659+
660+
.OUTPUTS
661+
System.Management.Automation.PSCustomObject
662+
663+
The paired archive SHA-256 and canonical payload SHA-256.
664+
#>
665+
[CmdletBinding()]
666+
[OutputType([PSCustomObject])]
667+
param(
668+
# The exact local release ZIP.
669+
[Parameter(Mandatory)]
670+
[ValidateNotNullOrEmpty()]
671+
[string] $Path,
672+
673+
# The expected single module root inside the ZIP.
674+
[Parameter(Mandatory)]
675+
[ValidateNotNullOrEmpty()]
676+
[string] $ModuleName
677+
)
678+
679+
$stream = [IO.File]::OpenRead($Path)
680+
$sha256 = [Security.Cryptography.SHA256]::Create()
681+
$archive = $null
682+
try {
683+
$assetDigest = [BitConverter]::ToString($sha256.ComputeHash($stream)).Replace('-', '').ToLowerInvariant()
684+
$stream.Position = 0
685+
$archive = [IO.Compression.ZipArchive]::new($stream, [IO.Compression.ZipArchiveMode]::Read, $true)
686+
$prefix = "$ModuleName/"
687+
$records = [Collections.Generic.List[object]]::new()
688+
foreach ($entry in $archive.Entries) {
689+
if (-not $entry.FullName.StartsWith($prefix, [StringComparison]::Ordinal)) {
690+
throw 'The release archive contains a foreign or unverifiable module root.'
691+
}
692+
$relativePath = $entry.FullName.Substring($prefix.Length)
693+
if ($entry.FullName.EndsWith('/')) {
694+
continue
695+
}
696+
if (Test-PSModulePackageMetadataPath -Path $relativePath) {
697+
throw 'The release archive contains a NuGet-reserved payload path.'
698+
}
699+
$entryStream = $entry.Open()
700+
try {
701+
$records.Add([pscustomobject]@{
702+
RelativePath = $relativePath
703+
Length = $entry.Length
704+
Hash = $sha256.ComputeHash($entryStream)
705+
})
706+
} finally {
707+
$entryStream.Dispose()
708+
}
709+
}
710+
if ($records.Count -eq 0) {
711+
throw 'The release archive contains no verifiable tested payload.'
712+
}
713+
[pscustomobject]@{
714+
ArtifactSha256 = Get-PSModuleContentDigest -File $records.ToArray()
715+
AssetSha256 = $assetDigest
716+
}
717+
} finally {
718+
if ($null -ne $archive) { $archive.Dispose() }
719+
$sha256.Dispose()
720+
$stream.Dispose()
721+
}
722+
}
723+
626724
function Get-PSModulePackageArtifactDigest {
627725
<#
628726
.SYNOPSIS

0 commit comments

Comments
 (0)