diff --git a/.github/workflows/test-standalone-install.yml b/.github/workflows/test-standalone-install.yml index e6aab2c585..11f59ba7ef 100644 --- a/.github/workflows/test-standalone-install.yml +++ b/.github/workflows/test-standalone-install.yml @@ -8,7 +8,9 @@ on: paths: - 'packages/cli/install.sh' - 'packages/cli/install.ps1' + - 'packages/cli/tests/fixtures/provenance-registry.mjs' - 'crates/vp_installer/**' + - 'crates/vp_global_cli/**' - 'crates/vp_pm_cli/**' - 'crates/vp_setup/**' - '.github/workflows/test-standalone-install.yml' @@ -114,6 +116,192 @@ jobs: vp upgrade --rollback vp --version + test-install-sh-provenance: + name: Test install.sh (npm provenance) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Verify platform package provenance before download + env: + TEST_VERSION: 9.9.9-provenance-test.1 + run: | + run_case() { + mode="$1" + expected="$2" + case_dir="$RUNNER_TEMP/vite-plus-provenance-sh-$mode" + port_file="$case_dir/port" + log_file="$case_dir/requests.jsonl" + vp_home="$case_dir/vp-home" + + rm -rf "$case_dir" + mkdir -p "$case_dir/home" "$vp_home" + node packages/cli/tests/fixtures/provenance-registry.mjs \ + --port-file "$port_file" \ + --log-file "$log_file" \ + --mode "$mode" \ + --version "$TEST_VERSION" & + server_pid=$! + + for _ in $(seq 1 100); do + [ -s "$port_file" ] && break + sleep 0.1 + done + if [ ! -s "$port_file" ]; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + echo "Mock registry did not start" + return 1 + fi + + registry="http://127.0.0.1:$(cat "$port_file")" + set +e + output=$(env \ + CI=true \ + HOME="$case_dir/home" \ + VP_HOME="$vp_home" \ + VP_NODE_MANAGER=no \ + VP_VERSION="$TEST_VERSION" \ + NPM_CONFIG_REGISTRY="$registry" \ + bash packages/cli/install.sh 2>&1) + status=$? + set -e + + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + printf '%s\n' "$output" + + if [ "$status" -eq 0 ]; then + echo "Expected the fixture tarball endpoint to prevent installation" + return 1 + fi + + if [ "$expected" = reject ]; then + printf '%s\n' "$output" | grep -F \ + "does not contain supported npm provenance metadata" + printf '%s\n' "$output" | grep -F \ + "@voidzero-dev/vite-plus-cli-" + printf '%s\n' "$output" | grep -F "$TEST_VERSION" + + if grep -F '"path":"/platform.tgz"' "$log_file"; then + echo "Platform tarball was requested before provenance validation" + return 1 + fi + if [ -e "$vp_home/current" ] || [ -e "$vp_home/$TEST_VERSION/bin/vp" ]; then + echo "Rejected package left an active or executable installation" + return 1 + fi + else + if printf '%s\n' "$output" | grep -F \ + "does not contain supported npm provenance metadata"; then + echo "Supported provenance metadata was rejected" + return 1 + fi + grep -F '"path":"/platform.tgz"' "$log_file" + fi + } + + run_case missing reject + run_case malformed reject + run_case top-level-only reject + run_case dotted-top-level-key reject + run_case unsupported reject + run_case valid-v1 allow + run_case valid-v0.2 allow + + test-vp-upgrade-provenance: + name: Test vp upgrade (npm provenance) + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + - uses: ./.github/actions/clone + + - uses: oxc-project/setup-rust@68c3199c5339f965e6e163924c3c450773eba42b # main (pending v1.0.17 — Swatinem/rust-cache v2.9.1 for node24) + + - name: Test shared provenance resolver + run: cargo test -p vp_setup registry + + - name: Build global vp + run: cargo build -p vp_global_cli + + - name: Verify upgrade provenance before download + env: + TEST_VERSION: 9.9.9-provenance-test.1 + run: | + case_dir="$RUNNER_TEMP/vite-plus-provenance-upgrade" + port_file="$case_dir/port" + log_file="$case_dir/requests.jsonl" + rm -rf "$case_dir" + mkdir -p "$case_dir" + + node packages/cli/tests/fixtures/provenance-registry.mjs \ + --port-file "$port_file" \ + --log-file "$log_file" \ + --mode missing \ + --version "$TEST_VERSION" & + server_pid=$! + cleanup() { + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + } + trap cleanup EXIT + + for _ in $(seq 1 100); do + [ -s "$port_file" ] && break + sleep 0.1 + done + if [ ! -s "$port_file" ]; then + echo "Mock registry did not start" + exit 1 + fi + + registry="http://127.0.0.1:$(cat "$port_file")" + run_upgrade() { + label="$1" + shift + home_dir="$case_dir/home-$label" + vp_home="$case_dir/vp-home-$label" + mkdir -p "$home_dir" + + set +e + output=$(env \ + CI=true \ + HOME="$home_dir" \ + VP_HOME="$vp_home" \ + target/debug/vp upgrade "$TEST_VERSION" --registry "$registry" "$@" 2>&1) + status=$? + set -e + printf '%s\n' "$output" + + if [ "$status" -eq 0 ]; then + echo "Expected vp upgrade $label to reject missing provenance" + return 1 + fi + printf '%s\n' "$output" | grep -F \ + "does not contain supported npm provenance metadata" + printf '%s\n' "$output" | grep -F \ + "@voidzero-dev/vite-plus-cli-" + printf '%s\n' "$output" | grep -F "$TEST_VERSION" + + if [ -e "$vp_home/current" ] || [ -e "$vp_home/$TEST_VERSION" ]; then + echo "Rejected upgrade changed the active or target version" + return 1 + fi + } + + run_upgrade install + run_upgrade check --check + + if grep -F '"path":"/platform.tgz"' "$log_file"; then + echo "Platform tarball was requested before provenance validation" + exit 1 + fi + grep -F '"path":"/@voidzero-dev/vite-plus-cli-' "$log_file" + test-install-sh-readonly-config: name: Test install.sh (readonly shell config) runs-on: ubuntu-latest @@ -570,6 +758,137 @@ jobs: vp upgrade --rollback vp --version + test-install-ps1-provenance: + name: Test install.ps1 (npm provenance, Windows PowerShell 5.1) + runs-on: namespace-profile-windows-4c-8g + permissions: + contents: read + steps: + - uses: taiki-e/checkout-action@7d1e50e93dc4fb3bba58f85018fadf77898aee8b # v1.4.2 + + - name: Verify platform package provenance before download + shell: powershell + env: + TEST_VERSION: 9.9.9-provenance-test.1 + run: | + $ErrorActionPreference = "Stop" + + function Invoke-ProvenanceCase { + param( + [string]$Mode, + [bool]$ExpectRejection, + [bool]$RawContentType = $false + ) + + $caseDir = Join-Path $env:RUNNER_TEMP "vite-plus-provenance-ps1-$Mode" + $homeDir = Join-Path $caseDir "home" + $vpHome = Join-Path $caseDir "vp-home" + $portFile = Join-Path $caseDir "port" + $logFile = Join-Path $caseDir "requests.jsonl" + $stdoutFile = Join-Path $caseDir "registry.stdout.log" + $stderrFile = Join-Path $caseDir "registry.stderr.log" + $installerStdoutFile = Join-Path $caseDir "installer.stdout.log" + $installerStderrFile = Join-Path $caseDir "installer.stderr.log" + + Remove-Item -Path $caseDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $homeDir, $vpHome -Force | Out-Null + + $fixture = Join-Path (Get-Location) "packages/cli/tests/fixtures/provenance-registry.mjs" + $serverArgs = @( + $fixture, + "--port-file", $portFile, + "--log-file", $logFile, + "--mode", $Mode, + "--version", $env:TEST_VERSION + ) + if ($RawContentType) { + $serverArgs += @("--raw-content-type", "true") + } + + $server = Start-Process -FilePath "node" -ArgumentList $serverArgs -PassThru -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + + try { + for ($attempt = 0; $attempt -lt 100 -and -not (Test-Path $portFile); $attempt++) { + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path $portFile)) { + throw "Mock registry did not start: $(Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue)" + } + + $registry = "http://127.0.0.1:$(Get-Content $portFile -Raw)" + $env:CI = "true" + $env:USERPROFILE = $homeDir + $env:VP_HOME = $vpHome + $env:VP_NODE_MANAGER = "no" + $env:VP_VERSION = $env:TEST_VERSION + $env:NPM_CONFIG_REGISTRY = $registry + + # Windows PowerShell 5.1 turns redirected native stderr into a + # NativeCommandError. Capture each stream separately so the + # expected tarball failure cannot stop this parent test script. + $installerArgs = @( + "-NoProfile", + "-ExecutionPolicy", "Bypass", + "-File", ".\packages\cli\install.ps1" + ) + $installer = Start-Process -FilePath "powershell.exe" -ArgumentList $installerArgs -PassThru -Wait -RedirectStandardOutput $installerStdoutFile -RedirectStandardError $installerStderrFile + $exitCode = $installer.ExitCode + $text = @( + Get-Content -Path $installerStdoutFile -Raw -ErrorAction SilentlyContinue + Get-Content -Path $installerStderrFile -Raw -ErrorAction SilentlyContinue + ) -join "`n" + } finally { + if (-not $server.HasExited) { + Stop-Process -Id $server.Id -Force + $server.WaitForExit() + } + } + + Write-Host $text + if ($exitCode -eq 0) { + throw "Expected the fixture tarball endpoint to prevent installation" + } + + $requests = Get-Content -Path $logFile -Raw + $tarballRequested = $requests.Contains('"path":"/platform.tgz"') + $provenanceError = "does not contain supported npm provenance metadata" + + if ($ExpectRejection) { + if (-not $text.Contains($provenanceError)) { + throw "Expected provenance rejection for $Mode" + } + if (-not $text.Contains("@voidzero-dev/vite-plus-cli-") -or + -not $text.Contains($env:TEST_VERSION)) { + throw "Expected rejected package name and version in installer output" + } + if ($tarballRequested) { + throw "Platform tarball was requested before provenance validation" + } + if ((Test-Path (Join-Path $vpHome "current")) -or + (Test-Path (Join-Path $vpHome "$($env:TEST_VERSION)\bin\vp.exe"))) { + throw "Rejected package left an active or executable installation" + } + } else { + if ($text.Contains($provenanceError)) { + throw "Supported provenance metadata was rejected" + } + if (-not $tarballRequested) { + throw "Supported provenance metadata did not reach the tarball endpoint" + } + } + + # The child installer and the deliberate tarball failure are expected. + $global:LASTEXITCODE = 0 + } + + Invoke-ProvenanceCase -Mode "missing" -ExpectRejection $true + Invoke-ProvenanceCase -Mode "malformed" -ExpectRejection $true + Invoke-ProvenanceCase -Mode "top-level-only" -ExpectRejection $true -RawContentType $true + Invoke-ProvenanceCase -Mode "dotted-top-level-key" -ExpectRejection $true + Invoke-ProvenanceCase -Mode "unsupported" -ExpectRejection $true + Invoke-ProvenanceCase -Mode "valid-v1" -ExpectRejection $false + Invoke-ProvenanceCase -Mode "valid-v0.2" -ExpectRejection $false + test-install-ps1-release-age: name: Test install.ps1 (minimum-release-age) runs-on: namespace-profile-windows-4c-8g @@ -843,6 +1162,74 @@ jobs: shell: bash run: cargo build --release -p vp_installer + - name: Verify vp-setup.exe provenance before download + shell: pwsh + env: + TEST_VERSION: 9.9.9-provenance-test.1 + run: | + $ErrorActionPreference = "Stop" + $caseDir = Join-Path $env:RUNNER_TEMP "vite-plus-provenance-vp-setup" + $installDir = Join-Path $caseDir "install" + $portFile = Join-Path $caseDir "port" + $logFile = Join-Path $caseDir "requests.jsonl" + $stdoutFile = Join-Path $caseDir "registry.stdout.log" + $stderrFile = Join-Path $caseDir "registry.stderr.log" + Remove-Item -Path $caseDir -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $caseDir -Force | Out-Null + + $fixture = Join-Path (Get-Location) "packages/cli/tests/fixtures/provenance-registry.mjs" + $serverArgs = @( + $fixture, + "--port-file", $portFile, + "--log-file", $logFile, + "--mode", "missing", + "--version", $env:TEST_VERSION + ) + $server = Start-Process -FilePath "node" -ArgumentList $serverArgs -PassThru -RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile + + try { + for ($attempt = 0; $attempt -lt 100 -and -not (Test-Path $portFile); $attempt++) { + Start-Sleep -Milliseconds 100 + } + if (-not (Test-Path $portFile)) { + throw "Mock registry did not start: $(Get-Content $stderrFile -Raw -ErrorAction SilentlyContinue)" + } + + $registry = "http://127.0.0.1:$(Get-Content $portFile -Raw)" + $binary = "${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }}" + $output = & $binary --yes --no-node-manager --no-modify-path --install-dir $installDir --registry $registry --version $env:TEST_VERSION 2>&1 + $exitCode = $LASTEXITCODE + $text = $output -join "`n" + } finally { + if (-not $server.HasExited) { + Stop-Process -Id $server.Id -Force + $server.WaitForExit() + } + } + + Write-Host $text + if ($exitCode -eq 0) { + throw "Expected vp-setup.exe to reject missing provenance" + } + if (-not $text.Contains("does not contain supported npm provenance metadata") -or + -not $text.Contains("@voidzero-dev/vite-plus-cli-") -or + -not $text.Contains($env:TEST_VERSION)) { + throw "Expected provenance rejection with the package name and version" + } + + $requests = Get-Content -Path $logFile -Raw + if ($requests.Contains('"path":"/platform.tgz"')) { + throw "Platform tarball was requested before provenance validation" + } + if ((Test-Path (Join-Path $installDir "current")) -or + (Test-Path (Join-Path $installDir "bin\vp.exe")) -or + (Test-Path (Join-Path $installDir $env:TEST_VERSION))) { + throw "Rejected package left a partial vp-setup.exe installation" + } + + # The child installer is expected to fail in this test. + $global:LASTEXITCODE = 0 + - name: Install via vp-setup.exe (silent) shell: pwsh run: ${{ format('{0}/target/release/vp-setup.exe', env.DEV_DRIVE) }} diff --git a/Cargo.lock b/Cargo.lock index 532818cc61..1edd8424ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8639,6 +8639,7 @@ version = "0.0.0" dependencies = [ "base64-simd", "flate2", + "httpmock", "junction", "node-semver", "serde", diff --git a/crates/vp_setup/Cargo.toml b/crates/vp_setup/Cargo.toml index 0d0b691ec9..09d2dc5e25 100644 --- a/crates/vp_setup/Cargo.toml +++ b/crates/vp_setup/Cargo.toml @@ -28,6 +28,7 @@ vt_str = { workspace = true } junction = { workspace = true } [dev-dependencies] +httpmock = { workspace = true } tempfile = { workspace = true } [lib] diff --git a/crates/vp_setup/src/error.rs b/crates/vp_setup/src/error.rs index 0dd6467eab..f9a5cadbd2 100644 --- a/crates/vp_setup/src/error.rs +++ b/crates/vp_setup/src/error.rs @@ -21,4 +21,9 @@ pub enum Error { #[error("Unsupported integrity format: {0} (only sha512 is supported)")] UnsupportedIntegrity(Str), + + #[error( + "Refusing to install {package}@{version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." + )] + UnsupportedPlatformPackageProvenance { package: Str, version: Str }, } diff --git a/crates/vp_setup/src/registry.rs b/crates/vp_setup/src/registry.rs index 7bf8465430..e98dd64ff0 100644 --- a/crates/vp_setup/src/registry.rs +++ b/crates/vp_setup/src/registry.rs @@ -20,6 +20,22 @@ pub struct PackageVersionMetadata { pub struct DistInfo { pub tarball: String, pub integrity: String, + #[serde(default)] + pub attestations: Option, +} + +/// npm attestations attached to a package version. +#[derive(Debug, Deserialize)] +pub struct NpmAttestations { + #[serde(default)] + pub provenance: Option, +} + +/// npm provenance metadata used to identify the attestation predicate. +#[derive(Debug, Deserialize)] +pub struct NpmProvenance { + #[serde(rename = "predicateType", default)] + pub predicate_type: Option, } /// Resolved version info with URLs and integrity for the platform package. @@ -33,6 +49,32 @@ pub struct ResolvedVersion { const MAIN_PACKAGE_NAME: &str = "vite-plus"; const PLATFORM_PACKAGE_SCOPE: &str = "@voidzero-dev"; const CLI_PACKAGE_NAME_PREFIX: &str = "vite-plus-cli"; +const SUPPORTED_PROVENANCE_PREDICATE_TYPES: [&str; 2] = + ["https://slsa.dev/provenance/v1", "https://slsa.dev/provenance/v0.2"]; + +fn validate_platform_package_provenance( + package_name: &str, + version: &str, + dist: &DistInfo, +) -> Result<(), Error> { + let predicate_type = dist + .attestations + .as_ref() + .and_then(|attestations| attestations.provenance.as_ref()) + .and_then(|provenance| provenance.predicate_type.as_deref()) + .filter(|predicate_type| !predicate_type.is_empty()); + + if predicate_type.is_some_and(|predicate_type| { + SUPPORTED_PROVENANCE_PREDICATE_TYPES.contains(&predicate_type) + }) { + return Ok(()); + } + + Err(Error::UnsupportedPlatformPackageProvenance { + package: package_name.into(), + version: version.into(), + }) +} /// Resolve a version string from the npm registry. /// @@ -86,6 +128,11 @@ pub async fn resolve_platform_package( ) })?; + // npm registry signatures only prove that registry metadata was signed. The + // provenance object separately binds the package to its supported build + // attestation, so reject before exposing the tarball URL to any caller. + validate_platform_package_provenance(&cli_package_name, version, &cli_meta.dist)?; + Ok(ResolvedVersion { version: version.to_owned(), platform_tarball_url: cli_meta.dist.tarball, @@ -109,8 +156,34 @@ pub async fn resolve_version( #[cfg(test)] mod tests { + use httpmock::prelude::*; + use super::*; + const TEST_PACKAGE_NAME: &str = "@voidzero-dev/vite-plus-cli-darwin-arm64"; + const TEST_VERSION: &str = "1.2.3"; + + fn parse_metadata(dist: serde_json::Value) -> PackageVersionMetadata { + serde_json::from_value(serde_json::json!({ + "version": TEST_VERSION, + "dist": dist, + })) + .unwrap() + } + + fn dist_with_provenance(predicate_type: serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "signatures": [{ "keyid": "registry-signature-is-not-provenance" }], + "attestations": { + "provenance": { + "predicateType": predicate_type, + } + } + }) + } + #[test] fn test_cli_package_name_construction() { let suffix = "darwin-arm64"; @@ -118,6 +191,156 @@ mod tests { assert_eq!(name, "@voidzero-dev/vite-plus-cli-darwin-arm64"); } + #[test] + fn test_platform_package_accepts_supported_provenance_predicates() { + for predicate_type in SUPPORTED_PROVENANCE_PREDICATE_TYPES { + let metadata = parse_metadata(dist_with_provenance(predicate_type.into())); + assert!( + validate_platform_package_provenance( + TEST_PACKAGE_NAME, + TEST_VERSION, + &metadata.dist, + ) + .is_ok(), + "expected {predicate_type} to be accepted" + ); + } + } + + #[test] + fn test_platform_package_rejects_missing_or_unsupported_provenance() { + let cases = [ + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + }), + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "attestations": {}, + }), + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "attestations": { "provenance": {} }, + }), + dist_with_provenance("".into()), + dist_with_provenance(" https://slsa.dev/provenance/v1 ".into()), + dist_with_provenance("https://example.test/unknown-provenance/v1".into()), + serde_json::json!({ + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "signatures": [{ "keyid": "signature-only" }], + }), + ]; + + for dist in cases { + let metadata = parse_metadata(dist); + let error = validate_platform_package_provenance( + TEST_PACKAGE_NAME, + TEST_VERSION, + &metadata.dist, + ) + .unwrap_err(); + + match error { + Error::UnsupportedPlatformPackageProvenance { package, version } => { + assert_eq!(package.as_str(), TEST_PACKAGE_NAME); + assert_eq!(version.as_str(), TEST_VERSION); + } + other => panic!("unexpected error: {other:?}"), + } + } + } + + #[test] + fn test_platform_package_ignores_top_level_attestations() { + let metadata: PackageVersionMetadata = serde_json::from_value(serde_json::json!({ + "version": TEST_VERSION, + "attestations": { + "provenance": { + "predicateType": "https://slsa.dev/provenance/v1" + } + }, + "dist": { + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test" + } + })) + .unwrap(); + + assert!(matches!( + validate_platform_package_provenance(TEST_PACKAGE_NAME, TEST_VERSION, &metadata.dist,), + Err(Error::UnsupportedPlatformPackageProvenance { .. }) + )); + } + + #[test] + fn test_platform_package_metadata_rejects_malformed_provenance_shape() { + let result = serde_json::from_value::(serde_json::json!({ + "version": TEST_VERSION, + "dist": { + "tarball": "https://registry.example.test/platform.tgz", + "integrity": "sha512-test", + "attestations": { "provenance": "not-an-object" } + } + })); + + assert!(result.is_err()); + } + + #[tokio::test] + async fn test_resolve_platform_package_returns_verified_distribution() { + let server = MockServer::start(); + let metadata_mock = server.mock(|when, then| { + when.method(GET).path("/@voidzero-dev/vite-plus-cli-darwin-arm64/1.2.3"); + then.status(200).json_body(serde_json::json!({ + "version": TEST_VERSION, + "dist": dist_with_provenance("https://slsa.dev/provenance/v1".into()), + })); + }); + + let resolved = + resolve_platform_package(TEST_VERSION, "darwin-arm64", Some(&server.base_url())) + .await + .unwrap(); + + metadata_mock.assert(); + assert_eq!(resolved.version, TEST_VERSION); + assert_eq!(resolved.platform_tarball_url, "https://registry.example.test/platform.tgz"); + assert_eq!(resolved.platform_integrity, "sha512-test"); + } + + #[tokio::test] + async fn test_resolve_platform_package_rejects_before_returning_distribution() { + let server = MockServer::start(); + let metadata_mock = server.mock(|when, then| { + when.method(GET).path("/@voidzero-dev/vite-plus-cli-darwin-arm64/1.2.3"); + then.status(200).json_body(serde_json::json!({ + "version": TEST_VERSION, + "dist": { + "tarball": format!("{}/platform.tgz", server.base_url()), + "integrity": "sha512-test", + } + })); + }); + let tarball_mock = server.mock(|when, then| { + when.method(GET).path("/platform.tgz"); + then.status(200).body("must not be downloaded"); + }); + + let error = + resolve_platform_package(TEST_VERSION, "darwin-arm64", Some(&server.base_url())) + .await + .unwrap_err(); + + metadata_mock.assert(); + assert_eq!(tarball_mock.hits(), 0); + assert!(matches!(error, Error::UnsupportedPlatformPackageProvenance { .. })); + assert!(error.to_string().contains(TEST_PACKAGE_NAME)); + assert!(error.to_string().contains(TEST_VERSION)); + } + #[test] fn test_all_platform_suffixes_match_published_cli_packages() { // These are the actual published CLI package suffixes diff --git a/packages/cli/install.ps1 b/packages/cli/install.ps1 index c37507a314..2e4da695b7 100644 --- a/packages/cli/install.ps1 +++ b/packages/cli/install.ps1 @@ -35,6 +35,10 @@ $PrVersion = $env:VP_PR_VERSION # pulls a coherent, clearly-defined test build. $BridgeDownloadBase = "https://registry-bridge.viteplus.dev/voidzero-dev/vite-plus" $BridgeRegistry = "https://registry-bridge.viteplus.dev/" +$SupportedProvenancePredicateTypes = @( + "https://slsa.dev/provenance/v1", + "https://slsa.dev/provenance/v0.2" +) function Write-Info { param([string]$Message) @@ -482,6 +486,72 @@ function Get-VersionFromMetadata { return $metadata.version } +function Get-PlatformPackageMetadata { + param( + [string]$PackageName, + [string]$Version + ) + + $encodedPackageName = [System.Uri]::EscapeDataString($PackageName) + $metadataUrl = "$NpmRegistry/$encodedPackageName/$Version" + try { + $metadata = Invoke-RestMethod -Uri $metadataUrl -Headers @{ Accept = "application/json" } + } catch { + if (Test-IsInstallStopException $_) { throw } + $errorMsg = $_.ErrorDetails.Message + if ($errorMsg) { + try { + $errorJson = $errorMsg | ConvertFrom-Json + if ($errorJson.error) { + Write-Error-Exit "Failed to fetch CLI package metadata '${PackageName}@${Version}': $($errorJson.error)`n URL: $metadataUrl" + } + } catch { + if (Test-IsInstallStopException $_) { throw } + # JSON parsing failed, fall through to the generic network error. + } + } + Write-Error-Exit "Failed to fetch CLI package metadata from: $metadataUrl`nError: $_" + } + + # Some custom registries return JSON using a non-JSON content type. Match + # Get-PackageMetadata by parsing that raw string before inspecting fields. + if ($metadata -is [string]) { + try { + $metadata = $metadata | ConvertFrom-Json + } catch { + if (Test-IsInstallStopException $_) { throw } + Write-Error-Exit "Failed to parse CLI package metadata '${PackageName}@${Version}'`n URL: $metadataUrl" + } + } + if ($metadata.error) { + Write-Error-Exit "Failed to fetch CLI package metadata '${PackageName}@${Version}': $($metadata.error)`n URL: $metadataUrl" + } + + return $metadata +} + +function Get-VerifiedPlatformTarballUrl { + param( + [object]$Metadata, + [string]$PackageName, + [string]$Version + ) + + # Registry signatures and trusted-publisher labels are not substitutes for + # npm provenance. Check the typed object path so package-defined top-level + # fields cannot satisfy the gate, and deny unknown predicates before download. + $predicateType = $Metadata.dist.attestations.provenance.predicateType + if (-not $predicateType -or $SupportedProvenancePredicateTypes -notcontains $predicateType) { + Write-Error-Exit "Refusing to install ${PackageName}@${Version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." + } + + $tarballUrl = $Metadata.dist.tarball + if (-not $tarballUrl) { + Write-Error-Exit "CLI package metadata for ${PackageName}@${Version} does not include dist.tarball" + } + return [string]$tarballUrl +} + function Get-PlatformSuffix { param([string]$Platform) # Windows needs -msvc suffix, other platforms map directly @@ -811,7 +881,8 @@ function Main { $platformUrl = "$BridgeDownloadBase/@voidzero-dev/vite-plus-cli-$platformSuffix@$PrVersion" } else { $packageName = "@voidzero-dev/vite-plus-cli-$platformSuffix" - $platformUrl = "$NpmRegistry/$packageName/-/vite-plus-cli-$platformSuffix-$ViteVersion.tgz" + $platformMetadata = Get-PlatformPackageMetadata -PackageName $packageName -Version $ViteVersion + $platformUrl = Get-VerifiedPlatformTarballUrl -Metadata $platformMetadata -PackageName $packageName -Version $ViteVersion } $platformTempFile = New-TemporaryFile diff --git a/packages/cli/install.sh b/packages/cli/install.sh index 5aa2244fab..251c39d3dd 100644 --- a/packages/cli/install.sh +++ b/packages/cli/install.sh @@ -465,6 +465,7 @@ check_requirements() { # Fetch package metadata from npm registry (cached for reuse) # Uses VP_VERSION to fetch the correct version's metadata PACKAGE_METADATA="" +PLATFORM_TARBALL_URL="" fetch_package_metadata() { if [ -z "$PACKAGE_METADATA" ]; then local version_path metadata_url @@ -509,6 +510,258 @@ get_version_from_metadata() { fi } +# Extract the platform tarball URL and provenance predicate from npm version +# metadata. Bootstrap runs before Node.js is available and cannot require jq, +# so this parser tracks each JSON path segment and container boundary rather +# than matching key names or dot-joined paths. Keeping segments separate means +# a package-defined key containing dots cannot impersonate npm's nested +# `dist.attestations.provenance` metadata. Invalid JSON fails closed. +parse_platform_distribution_metadata() { + awk ' + function fail_json(message) { + print message > "/dev/stderr" + exit 2 + } + + function skip_whitespace( c) { + while (json_pos <= json_length) { + c = substr(json_text, json_pos, 1) + if (c == " " || c == "\t" || c == "\r" || c == "\n") { + json_pos++ + } else { + return + } + } + } + + function parse_string( result, c, escaped, hex) { + skip_whitespace() + if (substr(json_text, json_pos, 1) != "\"") { + fail_json("expected JSON string") + } + json_pos++ + + while (json_pos <= json_length) { + c = substr(json_text, json_pos, 1) + json_pos++ + if (c == "\"") { + return result + } + if (c == "\\") { + if (json_pos > json_length) { + fail_json("unterminated JSON escape") + } + escaped = substr(json_text, json_pos, 1) + json_pos++ + if (escaped == "\"" || escaped == "\\" || escaped == "/") { + result = result escaped + } else if (escaped == "b" || escaped == "f" || escaped == "n" || escaped == "r" || escaped == "t") { + # Keep control escapes printable so extracted values cannot inject lines. + result = result "\\" escaped + } else if (escaped == "u") { + hex = substr(json_text, json_pos, 4) + if (length(hex) != 4 || hex ~ /[^0-9A-Fa-f]/) { + fail_json("invalid JSON unicode escape") + } + result = result "\\u" hex + json_pos += 4 + } else { + fail_json("invalid JSON escape") + } + } else { + if (c ~ /[[:cntrl:]]/) { + fail_json("unescaped control character in JSON string") + } + result = result c + } + } + + fail_json("unterminated JSON string") + } + + function is_object_key(depth, key) { + return path_kind[depth] == "object-key" && path_key[depth] == key + } + + function remember_string(depth, value) { + if (depth == 2 && is_object_key(1, "dist") && is_object_key(2, "tarball")) { + if (++tarball_count != 1) fail_json("duplicate dist.tarball") + tarball = value + } else if (depth == 4 && is_object_key(1, "dist") && + is_object_key(2, "attestations") && + is_object_key(3, "provenance") && + is_object_key(4, "predicateType")) { + if (++predicate_count != 1) fail_json("duplicate provenance predicateType") + predicate_type = value + } else if (depth == 1 && is_object_key(1, "error")) { + if (++error_count != 1) fail_json("duplicate registry error") + registry_error = value + } + } + + function parse_number( start, value, c) { + start = json_pos + while (json_pos <= json_length) { + c = substr(json_text, json_pos, 1) + if (c ~ /[-+0-9.eE]/) json_pos++ + else break + } + value = substr(json_text, start, json_pos - start) + if (value !~ /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/) { + fail_json("invalid JSON number") + } + } + + function parse_literal(literal) { + if (substr(json_text, json_pos, length(literal)) != literal) { + fail_json("invalid JSON literal") + } + json_pos += length(literal) + } + + function parse_array(depth, c, child_depth) { + json_pos++ + skip_whitespace() + if (substr(json_text, json_pos, 1) == "]") { + json_pos++ + return + } + + while (1) { + child_depth = depth + 1 + path_kind[child_depth] = "array-item" + path_key[child_depth] = "" + parse_value(child_depth) + delete path_kind[child_depth] + delete path_key[child_depth] + skip_whitespace() + c = substr(json_text, json_pos, 1) + if (c == "]") { + json_pos++ + return + } + if (c != ",") fail_json("expected comma in JSON array") + json_pos++ + } + } + + function parse_object(depth, key, child_depth, c, object_id) { + json_pos++ + object_id = ++object_count + skip_whitespace() + if (substr(json_text, json_pos, 1) == "}") { + json_pos++ + return + } + + while (1) { + key = parse_string() + if ((object_id SUBSEP key) in object_keys) { + fail_json("duplicate key in JSON object") + } + object_keys[object_id SUBSEP key] = 1 + skip_whitespace() + if (substr(json_text, json_pos, 1) != ":") { + fail_json("expected colon in JSON object") + } + json_pos++ + child_depth = depth + 1 + path_kind[child_depth] = "object-key" + path_key[child_depth] = key + parse_value(child_depth) + delete path_kind[child_depth] + delete path_key[child_depth] + skip_whitespace() + c = substr(json_text, json_pos, 1) + if (c == "}") { + json_pos++ + return + } + if (c != ",") fail_json("expected comma in JSON object") + json_pos++ + } + } + + function parse_value(depth, c, value) { + skip_whitespace() + c = substr(json_text, json_pos, 1) + if (c == "{") { + parse_object(depth) + } else if (c == "[") { + parse_array(depth) + } else if (c == "\"") { + value = parse_string() + remember_string(depth, value) + } else if (c == "t") { + parse_literal("true") + } else if (c == "f") { + parse_literal("false") + } else if (c == "n") { + parse_literal("null") + } else if (c == "-" || c ~ /[0-9]/) { + parse_number() + } else { + fail_json("invalid JSON value") + } + } + + { json_text = json_text $0 "\n" } + + END { + json_length = length(json_text) + json_pos = 1 + parse_value(0) + skip_whitespace() + if (json_pos <= json_length) fail_json("unexpected data after JSON value") + + print tarball + print predicate_type + print registry_error + } + ' +} + +# Fetch exact platform package metadata and admit only npm provenance predicate +# types supported by Vite+. `dist.signatures` is deliberately insufficient: it +# authenticates registry metadata, while provenance binds this release binary +# to the build that produced it. Any missing or unrecognized evidence is denied +# before the tarball URL is used. +resolve_platform_distribution() { + local package_name="$1" + local package_version="$2" + local encoded_package_name="${package_name/\//%2F}" + local metadata_url="${NPM_REGISTRY}/${encoded_package_name}/${package_version}" + local metadata parsed registry_error predicate_type + + metadata=$(curl_with_error_handling -s "$metadata_url") + if [ -z "$metadata" ]; then + error "Failed to fetch CLI package metadata from: $metadata_url" + fi + + if ! parsed=$(printf '%s\n' "$metadata" | parse_platform_distribution_metadata); then + error "Failed to parse CLI package metadata for ${package_name}@${package_version}\n URL: $metadata_url" + fi + + PLATFORM_TARBALL_URL=$(printf '%s\n' "$parsed" | sed -n '1p') + predicate_type=$(printf '%s\n' "$parsed" | sed -n '2p') + registry_error=$(printf '%s\n' "$parsed" | sed -n '3p') + + if [ -n "$registry_error" ]; then + error "Failed to fetch CLI package metadata '${package_name}@${package_version}': ${registry_error}\n URL: $metadata_url" + fi + + case "$predicate_type" in + https://slsa.dev/provenance/v1|https://slsa.dev/provenance/v0.2) ;; + *) + error "Refusing to install ${package_name}@${package_version}: the package does not contain supported npm provenance metadata. Vite+ only installs release binaries published with npm provenance." + ;; + esac + + if [ -z "$PLATFORM_TARBALL_URL" ]; then + error "CLI package metadata for ${package_name}@${package_version} does not include dist.tarball\n URL: $metadata_url" + fi +} + # Get platform suffix for CLI package download # Sets PLATFORM_SUFFIX global variable # Platform format from detect_platform(): darwin-arm64, darwin-x64, linux-x64-gnu, linux-arm64-gnu, win32-x64, etc. @@ -1086,7 +1339,8 @@ main() { platform_url="${BRIDGE_DOWNLOAD_BASE}/@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}@${PR_VERSION}" else local package_name="@voidzero-dev/vite-plus-cli-${PLATFORM_SUFFIX}" - platform_url="${NPM_REGISTRY}/${package_name}/-/vite-plus-cli-${PLATFORM_SUFFIX}-${VP_VERSION}.tgz" + resolve_platform_distribution "$package_name" "$VP_VERSION" + platform_url="$PLATFORM_TARBALL_URL" fi # Create temp directory for extraction diff --git a/packages/cli/tests/fixtures/provenance-registry.mjs b/packages/cli/tests/fixtures/provenance-registry.mjs new file mode 100644 index 0000000000..156fed5331 --- /dev/null +++ b/packages/cli/tests/fixtures/provenance-registry.mjs @@ -0,0 +1,137 @@ +import { appendFileSync, writeFileSync } from 'node:fs'; +import { createServer } from 'node:http'; + +const args = new Map(); +for (let index = 2; index < process.argv.length; index += 2) { + const name = process.argv[index]; + const value = process.argv[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid argument at position ${index}: ${name ?? ''}`); + } + args.set(name.slice(2), value); +} + +const portFile = args.get('port-file'); +const logFile = args.get('log-file'); +const mode = args.get('mode') ?? 'missing'; +const version = args.get('version') ?? '9.9.9-provenance-test.1'; +const rawContentType = args.get('raw-content-type') === 'true'; + +if (!portFile || !logFile) { + throw new Error('--port-file and --log-file are required'); +} +if ( + ![ + 'missing', + 'malformed', + 'top-level-only', + 'dotted-top-level-key', + 'unsupported', + 'valid-v1', + 'valid-v0.2', + ].includes(mode) +) { + throw new Error(`Unsupported mode: ${mode}`); +} + +writeFileSync(logFile, ''); + +function sendJson(response, status, body) { + const json = JSON.stringify(body); + response.writeHead(status, { + 'content-length': Buffer.byteLength(json), + 'content-type': rawContentType ? 'text/plain' : 'application/json', + }); + response.end(json); +} + +function platformMetadata(packageName, registryBase) { + const metadata = { + name: packageName, + version, + dist: { + tarball: `${registryBase}/platform.tgz`, + integrity: 'sha512-test-only', + signatures: [{ keyid: 'registry-signature-is-not-provenance', sig: 'test-only' }], + }, + }; + + if (mode === 'malformed') { + metadata.dist.attestations = { provenance: 'not-an-object' }; + } else if (mode === 'top-level-only') { + metadata.attestations = { + provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, + }; + } else if (mode === 'dotted-top-level-key') { + metadata['dist.attestations.provenance.predicateType'] = 'https://slsa.dev/provenance/v1'; + } else if (mode === 'unsupported') { + metadata.dist.attestations = { + provenance: { predicateType: 'https://example.test/provenance/v1' }, + }; + } else if (mode === 'valid-v1') { + metadata.dist.attestations = { + provenance: { predicateType: 'https://slsa.dev/provenance/v1' }, + }; + } else if (mode === 'valid-v0.2') { + metadata.dist.attestations = { + provenance: { predicateType: 'https://slsa.dev/provenance/v0.2' }, + }; + } + + return metadata; +} + +const server = createServer((request, response) => { + const url = new URL(request.url ?? '/', 'http://127.0.0.1'); + let decodedPath; + try { + decodedPath = decodeURIComponent(url.pathname); + } catch { + sendJson(response, 400, { error: 'invalid URL encoding' }); + return; + } + + appendFileSync(logFile, `${JSON.stringify({ method: request.method, path: decodedPath })}\n`); + const requestHost = request.headers.host ?? `127.0.0.1:${server.address().port}`; + const registryBase = `http://${requestHost}`; + + if (decodedPath === `/vite-plus/${version}`) { + sendJson(response, 200, { + name: 'vite-plus', + version, + dist: { + tarball: `${registryBase}/vite-plus.tgz`, + integrity: 'sha512-test-only', + }, + }); + return; + } + + const platformMatch = decodedPath.match( + new RegExp(`^/(@voidzero-dev/vite-plus-cli-[a-z0-9-]+)/${version.replaceAll('.', '\\.')}$`), + ); + if (platformMatch) { + sendJson(response, 200, platformMetadata(platformMatch[1], registryBase)); + return; + } + + if (decodedPath === '/platform.tgz' || decodedPath === '/vite-plus.tgz') { + response.writeHead(500, { 'content-type': 'text/plain' }); + response.end('The provenance gate must reject before requesting a tarball.\n'); + return; + } + + sendJson(response, 404, { error: `No fixture response for ${decodedPath}` }); +}); + +server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP listener'); + } + writeFileSync(portFile, String(address.port)); +}); + +for (const signal of ['SIGINT', 'SIGTERM']) { + process.on(signal, () => server.close(() => process.exit(0))); +}