From 2b25b89c9a00d9dfd17df57951b466c996f67a0a Mon Sep 17 00:00:00 2001 From: hatayama Date: Fri, 10 Jul 2026 13:13:41 +0900 Subject: [PATCH] fix(dispatcher): Verify project runner archive attestation before extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dispatcher project-runner download flow now requires a valid Sigstore attestation for each downloaded `uloop-project-runner-*.tar.gz` / `uloop-project-runner-*.zip` archive before it will extract and cache the runner binary. Fail-closed on bundle-missing, network-failure, digest-mismatch, and identity-mismatch — the .sha256 file ships from the same origin as the archive and cannot certify authenticity if that origin is compromised. This is Phase B3 of TODO 2 (Obsidian spec). Phase B2 (PR #1671, installer attestation) already merged; the shared verifier hook and identity constants from B2 are reused here with attestationRunnerPublishWorkflowPath. The existing TestDownloadDispatcherRealCLIWritesDownloadStatus test now stubs the attestation hook so it still verifies download status behavior without an outbound sigstore call. Two new tests cover the attestation integration: fail-closed on hook error, and the exact release tag + workflow path that get forwarded to the hook. --- .../dispatcher/dispatcher_download.go | 4 + .../internal/dispatcher/dispatcher_test.go | 129 ++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_download.go b/cli/dispatcher/internal/dispatcher/dispatcher_download.go index 46536b06b3..6d0d08a27b 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_download.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_download.go @@ -139,6 +139,7 @@ func downloadDispatcherRealCLIForPin(ctx context.Context, cacheRoot string, pin archivePath := filepath.Join(tempDir, assetName) checksumPath := archivePath + ".sha256" assetURL := dispatcherReleaseAssetURL(pin.ProjectRunnerVersion, assetName) + releaseTag := sharedupdate.ProjectRunnerReleaseTag(pin.ProjectRunnerVersion) clicore.WriteFormat(stderr, "uloop: downloading pinned project runner %s for %s...\n", pin.ProjectRunnerVersion, dispatcherPlatformName(goos, goarch)) if err := downloadDispatcherFile(ctx, assetURL, archivePath); err != nil { return "", err @@ -149,6 +150,9 @@ func downloadDispatcherRealCLIForPin(ctx context.Context, cacheRoot string, pin if err := verifyDispatcherChecksum(archivePath, checksumPath); err != nil { return "", err } + if err := verifyReleaseAssetAttestation(ctx, releaseTag, assetURL, archivePath, attestationRunnerPublishWorkflowPath); err != nil { + return "", err + } tempRealCLIPath := filepath.Join(tempDir, dispatcherRealCLIFileName(goos)) if err := extractDispatcherRealCLI(archivePath, assetName, tempRealCLIPath, goos); err != nil { diff --git a/cli/dispatcher/internal/dispatcher/dispatcher_test.go b/cli/dispatcher/internal/dispatcher/dispatcher_test.go index e33809f431..2e79b77579 100644 --- a/cli/dispatcher/internal/dispatcher/dispatcher_test.go +++ b/cli/dispatcher/internal/dispatcher/dispatcher_test.go @@ -9,6 +9,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "io" "net/http" "os" @@ -564,6 +565,8 @@ func TestDownloadDispatcherRealCLIWritesDownloadStatus(t *testing.T) { }, nil }), } + restoreAttestation := stubAttestationVerifyPasses() + defer restoreAttestation() var stderr bytes.Buffer realCLIPath, err := downloadDispatcherRealCLIForPin( @@ -584,6 +587,132 @@ func TestDownloadDispatcherRealCLIWritesDownloadStatus(t *testing.T) { assertFileContent(t, dispatcherRealCLIReadyPath(realCLIPath), "ready\n") } +func TestDownloadDispatcherRealCLIFailsClosedOnAttestationError(t *testing.T) { + // Verifies pinned project runners are rejected when the attestation verifier reports a mismatch, so a compromised release cannot install a runner even if its .sha256 lines up. + tempDir := t.TempDir() + archivePath := filepath.Join(tempDir, "uloop-project-runner-darwin-arm64.tar.gz") + writeDispatcherTarGzArchive(t, archivePath, []dispatcherArchiveTestEntry{ + {Name: "uloop-project-runner", Content: "real"}, + }) + archiveContent, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("failed to read archive: %v", err) + } + checksum := sha256.Sum256(archiveContent) + checksumContent := []byte(hex.EncodeToString(checksum[:]) + " " + filepath.Base(archivePath) + "\n") + + previousHTTPClient := dispatcherHTTPClient + defer func() { + dispatcherHTTPClient = previousHTTPClient + }() + dispatcherHTTPClient = &http.Client{ + Transport: dispatcherRoundTripFunc(func(request *http.Request) (*http.Response, error) { + content := []byte{} + statusCode := http.StatusNotFound + if strings.HasSuffix(request.URL.Path, "/uloop-project-runner-darwin-arm64.tar.gz") { + content = archiveContent + statusCode = http.StatusOK + } + if strings.HasSuffix(request.URL.Path, "/uloop-project-runner-darwin-arm64.tar.gz.sha256") { + content = checksumContent + statusCode = http.StatusOK + } + return &http.Response{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + Body: io.NopCloser(bytes.NewReader(content)), + }, nil + }), + } + restoreAttestation := stubAttestationVerifyReturns(fmt.Errorf("simulated runner attestation failure")) + defer restoreAttestation() + + var stderr bytes.Buffer + _, err = downloadDispatcherRealCLIForPin( + context.Background(), + t.TempDir(), + dispatcherPin{ProjectRunnerVersion: "3.0.0-beta.88"}, + "darwin", + "arm64", + &stderr) + if err == nil || !strings.Contains(err.Error(), "simulated runner attestation failure") { + t.Fatalf("expected attestation failure to fail closed, got %v", err) + } +} + +func TestDownloadDispatcherRealCLIPassesRunnerIdentityToAttestation(t *testing.T) { + // Verifies the runner-publish workflow SAN and the project runner release tag are what get sent to the attestation hook (Fable 5 review — SAN must match .github/workflows/native-cli-publish.yml, tag must be uloop-project-runner-v). + tempDir := t.TempDir() + archivePath := filepath.Join(tempDir, "uloop-project-runner-darwin-arm64.tar.gz") + writeDispatcherTarGzArchive(t, archivePath, []dispatcherArchiveTestEntry{ + {Name: "uloop-project-runner", Content: "real"}, + }) + archiveContent, err := os.ReadFile(archivePath) + if err != nil { + t.Fatalf("failed to read archive: %v", err) + } + checksum := sha256.Sum256(archiveContent) + checksumContent := []byte(hex.EncodeToString(checksum[:]) + " " + filepath.Base(archivePath) + "\n") + + previousHTTPClient := dispatcherHTTPClient + defer func() { + dispatcherHTTPClient = previousHTTPClient + }() + dispatcherHTTPClient = &http.Client{ + Transport: dispatcherRoundTripFunc(func(request *http.Request) (*http.Response, error) { + content := []byte{} + statusCode := http.StatusNotFound + if strings.HasSuffix(request.URL.Path, "/uloop-project-runner-darwin-arm64.tar.gz") { + content = archiveContent + statusCode = http.StatusOK + } + if strings.HasSuffix(request.URL.Path, "/uloop-project-runner-darwin-arm64.tar.gz.sha256") { + content = checksumContent + statusCode = http.StatusOK + } + return &http.Response{ + StatusCode: statusCode, + Status: http.StatusText(statusCode), + Body: io.NopCloser(bytes.NewReader(content)), + }, nil + }), + } + + var seenReleaseTag string + var seenAssetURL string + var seenWorkflowPath string + previous := verifyReleaseAssetAttestation + verifyReleaseAssetAttestation = func(_ context.Context, releaseTag string, assetURL string, _ string, workflowPath string) error { + seenReleaseTag = releaseTag + seenAssetURL = assetURL + seenWorkflowPath = workflowPath + return nil + } + defer func() { + verifyReleaseAssetAttestation = previous + }() + + var stderr bytes.Buffer + if _, err := downloadDispatcherRealCLIForPin( + context.Background(), + t.TempDir(), + dispatcherPin{ProjectRunnerVersion: "3.0.0-beta.88"}, + "darwin", + "arm64", + &stderr); err != nil { + t.Fatalf("downloadDispatcherRealCLIForPin failed: %v", err) + } + if seenReleaseTag != "uloop-project-runner-v3.0.0-beta.88" { + t.Fatalf("attestation hook received wrong release tag: %s", seenReleaseTag) + } + if !strings.HasSuffix(seenAssetURL, "/uloop-project-runner-v3.0.0-beta.88/uloop-project-runner-darwin-arm64.tar.gz") { + t.Fatalf("attestation hook received wrong asset URL: %s", seenAssetURL) + } + if seenWorkflowPath != attestationRunnerPublishWorkflowPath { + t.Fatalf("attestation hook received wrong workflow path: %s", seenWorkflowPath) + } +} + func TestInstallDownloadedDispatcherRealCLIKeepsExistingExecutable(t *testing.T) { // Verifies concurrent downloads do not delete a ready executable another dispatcher already cached. tempDir := t.TempDir()