microsoft/hve-core
Publicmirrored fromhttps://github.com/microsoft/hve-coreAvailable
scripts/security/Sign-PlannerArtifacts.ps1
249lines ยท modecode
| 1 | #!/usr/bin/env pwsh |
| 2 | # Copyright (c) Microsoft Corporation. |
| 3 | # SPDX-License-Identifier: MIT |
| 4 | |
| 5 | #Requires -Version 7.0 |
| 6 | |
| 7 | <# |
| 8 | .SYNOPSIS |
| 9 | Generates a SHA-256 manifest for planner artifacts (RAI or SSSC) and optionally signs it with cosign. |
| 10 | |
| 11 | .DESCRIPTION |
| 12 | Enumerates all files under a planner session directory, computes SHA-256 hashes for each |
| 13 | artifact, and writes a JSON manifest file. Supports RAI sessions via -ProjectSlug (resolved |
| 14 | to .copilot-tracking/rai-plans/{ProjectSlug}/) and arbitrary planner sessions via -SessionPath |
| 15 | (an absolute or repo-relative directory, e.g., .copilot-tracking/sssc-plans/{slug}/). When |
| 16 | cosign is available and requested, the manifest is signed using Sigstore keyless signing to |
| 17 | provide cryptographic provenance. |
| 18 | |
| 19 | .PARAMETER ProjectSlug |
| 20 | The project slug identifying an RAI planning session. Corresponds to the subdirectory under |
| 21 | .copilot-tracking/rai-plans/. Mutually exclusive with -SessionPath. |
| 22 | |
| 23 | .PARAMETER SessionPath |
| 24 | Direct path to a planner session directory (absolute, or relative to the repository root). |
| 25 | Use this for SSSC sessions or any non-RAI planner. Mutually exclusive with -ProjectSlug. |
| 26 | |
| 27 | .PARAMETER ManifestName |
| 28 | File name for the generated manifest written inside the session directory. Defaults to |
| 29 | 'artifact-manifest.json'. Ignored when -OutputPath is supplied. |
| 30 | |
| 31 | .PARAMETER OutputPath |
| 32 | Full path for the generated manifest file. When omitted, the manifest is written inside the |
| 33 | resolved session directory using -ManifestName. |
| 34 | |
| 35 | .PARAMETER IncludeCosign |
| 36 | When specified, attempts to sign the manifest with cosign keyless signing after |
| 37 | generation. Requires cosign to be available in PATH. Gracefully skips signing with |
| 38 | a warning when cosign is not found. |
| 39 | |
| 40 | .EXAMPLE |
| 41 | ./scripts/security/Sign-PlannerArtifacts.ps1 -ProjectSlug "contoso-ai" |
| 42 | |
| 43 | Generates a SHA-256 manifest for all artifacts under |
| 44 | .copilot-tracking/rai-plans/contoso-ai/. |
| 45 | |
| 46 | .EXAMPLE |
| 47 | ./scripts/security/Sign-PlannerArtifacts.ps1 -ProjectSlug "contoso-ai" -IncludeCosign |
| 48 | |
| 49 | Generates the manifest and signs it with cosign keyless signing. |
| 50 | |
| 51 | .EXAMPLE |
| 52 | npm run rai:sign -- -ProjectSlug "contoso-ai" -IncludeCosign |
| 53 | |
| 54 | Invokes the script through the npm wrapper with cosign signing enabled. |
| 55 | |
| 56 | .EXAMPLE |
| 57 | ./scripts/security/Sign-PlannerArtifacts.ps1 -SessionPath '.copilot-tracking/sssc-plans/contoso-supply-chain' -ManifestName 'sssc-manifest.json' |
| 58 | |
| 59 | Generates a manifest named sssc-manifest.json for an SSSC planner session. |
| 60 | |
| 61 | .NOTES |
| 62 | The manifest excludes its own file and any cosign signature files (.sig, .bundle) from the |
| 63 | hash inventory to avoid circular references. |
| 64 | |
| 65 | Under the BySessionPath parameter set, the manifest's projectSlug field is populated from |
| 66 | the session directory leaf rather than a canonical project slug. The field name is retained |
| 67 | for back-compatibility with existing RAI manifest consumers; callers that distinguish |
| 68 | between project slug and session label should rely on sessionPath instead. |
| 69 | #> |
| 70 | |
| 71 | [CmdletBinding(DefaultParameterSetName = 'ByProjectSlug')] |
| 72 | param( |
| 73 | [Parameter(Mandatory, ParameterSetName = 'ByProjectSlug')] |
| 74 | [ValidateNotNullOrEmpty()] |
| 75 | [string]$ProjectSlug, |
| 76 | |
| 77 | [Parameter(Mandatory, ParameterSetName = 'BySessionPath')] |
| 78 | [ValidateNotNullOrEmpty()] |
| 79 | [string]$SessionPath, |
| 80 | |
| 81 | [Parameter(Mandatory = $false)] |
| 82 | [string]$ManifestName = 'artifact-manifest.json', |
| 83 | |
| 84 | [Parameter(Mandatory = $false)] |
| 85 | [string]$OutputPath, |
| 86 | |
| 87 | [Parameter(Mandatory = $false)] |
| 88 | [switch]$IncludeCosign |
| 89 | ) |
| 90 | |
| 91 | $ErrorActionPreference = 'Stop' |
| 92 | |
| 93 | #region Helper Functions |
| 94 | |
| 95 | function Get-ArtifactHash { |
| 96 | <# |
| 97 | .SYNOPSIS |
| 98 | Computes the SHA-256 hash of a file and returns a lowercase hex string. |
| 99 | .OUTPUTS |
| 100 | [string] Lowercase hex SHA-256 digest. |
| 101 | #> |
| 102 | [CmdletBinding()] |
| 103 | [OutputType([string])] |
| 104 | param( |
| 105 | [Parameter(Mandatory)] |
| 106 | [string]$FilePath |
| 107 | ) |
| 108 | |
| 109 | (Get-FileHash -Path $FilePath -Algorithm SHA256).Hash.ToLower() |
| 110 | } |
| 111 | |
| 112 | #endregion Helper Functions |
| 113 | |
| 114 | #region Main Execution |
| 115 | if ($MyInvocation.InvocationName -ne '.') { |
| 116 | try { |
| 117 | #region Artifact Generation |
| 118 | |
| 119 | $repoRoot = & git rev-parse --show-toplevel 2>$null |
| 120 | if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($repoRoot)) { |
| 121 | $repoRoot = $PWD.Path |
| 122 | } |
| 123 | |
| 124 | if ($PSCmdlet.ParameterSetName -eq 'BySessionPath') { |
| 125 | if ([System.IO.Path]::IsPathRooted($SessionPath)) { |
| 126 | $artifactDir = $SessionPath |
| 127 | } |
| 128 | else { |
| 129 | $artifactDir = Join-Path -Path $repoRoot -ChildPath $SessionPath |
| 130 | } |
| 131 | $sessionLabel = Split-Path -Path $artifactDir -Leaf |
| 132 | } |
| 133 | else { |
| 134 | $artifactDir = Join-Path -Path $repoRoot -ChildPath ".copilot-tracking/rai-plans/$ProjectSlug" |
| 135 | $sessionLabel = $ProjectSlug |
| 136 | } |
| 137 | |
| 138 | if (-not (Test-Path -Path $artifactDir -PathType Container)) { |
| 139 | Write-Host "โ Artifact directory not found: $artifactDir" -ForegroundColor Red |
| 140 | exit 1 |
| 141 | } |
| 142 | |
| 143 | if (-not $OutputPath) { |
| 144 | $OutputPath = Join-Path -Path $artifactDir -ChildPath $ManifestName |
| 145 | } |
| 146 | |
| 147 | $manifestFileName = Split-Path -Path $OutputPath -Leaf |
| 148 | |
| 149 | # File patterns to exclude from the manifest to avoid circular references |
| 150 | $excludePatterns = @( |
| 151 | $manifestFileName, |
| 152 | '*.sig', |
| 153 | '*.bundle' |
| 154 | ) |
| 155 | |
| 156 | Write-Host "๐ Generating artifact manifest for session: $sessionLabel" -ForegroundColor Cyan |
| 157 | |
| 158 | $artifacts = Get-ChildItem -Path $artifactDir -File -Recurse | |
| 159 | Where-Object { |
| 160 | $fileName = $_.Name |
| 161 | -not ($excludePatterns | Where-Object { $fileName -like $_ }) |
| 162 | } | |
| 163 | Sort-Object FullName |
| 164 | |
| 165 | if ($artifacts.Count -eq 0) { |
| 166 | Write-Host "โ ๏ธ No artifacts found in: $artifactDir" -ForegroundColor Yellow |
| 167 | exit 0 |
| 168 | } |
| 169 | |
| 170 | Write-Host "๐ Found $($artifacts.Count) artifact(s) to hash" -ForegroundColor Cyan |
| 171 | |
| 172 | $fileEntries = [System.Collections.Generic.List[object]]::new() |
| 173 | |
| 174 | foreach ($file in $artifacts) { |
| 175 | $relativePath = $file.FullName.Substring($artifactDir.Length + 1) -replace '\\', '/' |
| 176 | $hash = Get-ArtifactHash -FilePath $file.FullName |
| 177 | $fileEntries.Add(@{ |
| 178 | path = $relativePath |
| 179 | sha256 = $hash |
| 180 | sizeBytes = $file.Length |
| 181 | }) |
| 182 | Write-Host " โ
$relativePath" -ForegroundColor Green |
| 183 | } |
| 184 | |
| 185 | $repoRootBoundary = if ($repoRoot.EndsWith([IO.Path]::DirectorySeparatorChar)) { $repoRoot } else { $repoRoot + [IO.Path]::DirectorySeparatorChar } |
| 186 | $manifest = [ordered]@{ |
| 187 | version = '1.0' |
| 188 | projectSlug = $sessionLabel |
| 189 | sessionPath = if ($artifactDir.Equals($repoRoot, [System.StringComparison]::OrdinalIgnoreCase)) { |
| 190 | '' |
| 191 | } elseif ($artifactDir.StartsWith($repoRootBoundary, [System.StringComparison]::OrdinalIgnoreCase)) { |
| 192 | ($artifactDir.Substring($repoRootBoundary.Length) -replace '\\','/') |
| 193 | } else { |
| 194 | ($artifactDir -replace '\\','/') |
| 195 | } |
| 196 | generatedAt = [DateTime]::UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ") |
| 197 | algorithm = 'SHA256' |
| 198 | fileCount = $fileEntries.Count |
| 199 | artifacts = $fileEntries.ToArray() |
| 200 | } |
| 201 | |
| 202 | $manifestJson = $manifest | ConvertTo-Json -Depth 10 |
| 203 | Set-Content -Path $OutputPath -Value $manifestJson -Encoding utf8NoBOM |
| 204 | |
| 205 | Write-Host "๐ Manifest written to: $OutputPath" -ForegroundColor Green |
| 206 | Write-Host " Files hashed: $($fileEntries.Count)" -ForegroundColor Cyan |
| 207 | |
| 208 | #endregion Artifact Generation |
| 209 | |
| 210 | #region Cosign Signing |
| 211 | |
| 212 | if ($IncludeCosign) { |
| 213 | $cosignCmd = Get-Command -Name 'cosign' -ErrorAction SilentlyContinue |
| 214 | |
| 215 | if (-not $cosignCmd) { |
| 216 | Write-Host "โ ๏ธ cosign not found in PATH. Skipping signature." -ForegroundColor Yellow |
| 217 | Write-Host " Install cosign from https://docs.sigstore.dev/cosign/system_config/installation/" -ForegroundColor Yellow |
| 218 | exit 0 |
| 219 | } |
| 220 | |
| 221 | Write-Host "๐ Signing manifest with cosign keyless signing..." -ForegroundColor Cyan |
| 222 | |
| 223 | try { |
| 224 | & cosign sign-blob ` |
| 225 | --yes ` |
| 226 | --output-signature "$OutputPath.sig" ` |
| 227 | --bundle "$OutputPath.bundle" ` |
| 228 | $OutputPath |
| 229 | |
| 230 | Write-Host "โ
Manifest signed successfully" -ForegroundColor Green |
| 231 | Write-Host " Signature: $OutputPath.sig" -ForegroundColor Cyan |
| 232 | Write-Host " Bundle: $OutputPath.bundle" -ForegroundColor Cyan |
| 233 | } |
| 234 | catch { |
| 235 | Write-Host "โ Cosign signing failed: $_" -ForegroundColor Red |
| 236 | exit 2 |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | #endregion Cosign Signing |
| 241 | |
| 242 | Write-Host "๐ Artifact signing complete" -ForegroundColor Green |
| 243 | } |
| 244 | catch { |
| 245 | Write-Error "Sign-PlannerArtifacts failed: $($_.Exception.Message)" -ErrorAction Continue |
| 246 | exit 1 |
| 247 | } |
| 248 | } |
| 249 | #endregion Main Execution |
| 250 | |