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()