From 758f31df05b7700be49c6fa8796a5e8236b0c8e0 Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Fri, 10 Oct 2025 19:27:15 +0000 Subject: [PATCH 1/2] fix(signing): explicitly fetch GitHub OIDC token for Sigstore Sigstore-go does not automatically fetch GitHub OIDC tokens from environment variables. This commit adds explicit token fetching logic to resolve signing failures in GitHub Actions. Changes: - Add fetchGitHubOIDCToken() to fetch token from GitHub OIDC endpoint - Update signProvenanceWithSigstore() to use fetched token explicitly - Add comprehensive unit tests for token fetching with error scenarios - Use context-aware HTTP requests with 30s timeout Fixes signing failures where Sigstore expected an explicit IDToken instead of auto-discovering from ACTIONS_ID_TOKEN_REQUEST_* env vars. Co-authored-by: Ona --- pkg/leeway/signing/attestation.go | 84 +++++++++++++++++- pkg/leeway/signing/attestation_test.go | 118 +++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 2 deletions(-) diff --git a/pkg/leeway/signing/attestation.go b/pkg/leeway/signing/attestation.go index dc013d7..bf74a0b 100644 --- a/pkg/leeway/signing/attestation.go +++ b/pkg/leeway/signing/attestation.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "io" + "net/http" + "net/url" "os" "path/filepath" "time" @@ -245,6 +247,17 @@ func signProvenanceWithSigstore(ctx context.Context, statement *in_toto.Statemen // Configure Fulcio for GitHub OIDC if we have a token if os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") != "" { + // Fetch the GitHub OIDC token for Sigstore + idToken, err := fetchGitHubOIDCToken(ctx, "sigstore") + if err != nil { + return nil, &SigningError{ + Type: ErrorTypeSigstore, + Artifact: statement.Subject[0].Name, + Message: fmt.Sprintf("failed to fetch GitHub OIDC token: %v", err), + Cause: err, + } + } + // Select Fulcio service from signing config fulcioService, err := root.SelectService(signingConfig.FulcioCertificateAuthorityURLs(), sign.FulcioAPIVersions, time.Now()) if err != nil { @@ -263,8 +276,7 @@ func signProvenanceWithSigstore(ctx context.Context, statement *in_toto.Statemen } bundleOpts.CertificateProvider = sign.NewFulcio(fulcioOpts) bundleOpts.CertificateProviderOptions = &sign.CertificateProviderOptions{ - // Let sigstore-go automatically handle GitHub OIDC - // It will use ACTIONS_ID_TOKEN_REQUEST_TOKEN/URL automatically + IDToken: idToken, } // Configure Rekor transparency log @@ -354,3 +366,71 @@ func validateSigstoreEnvironment() error { log.Debug("Sigstore environment validation passed") return nil } + +// fetchGitHubOIDCToken fetches an OIDC token from GitHub Actions for Sigstore. +// It uses the ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL +// environment variables to authenticate and retrieve a JWT token with the specified audience. +func fetchGitHubOIDCToken(ctx context.Context, audience string) (string, error) { + requestURL := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") + requestToken := os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") + + if requestURL == "" || requestToken == "" { + return "", fmt.Errorf("GitHub OIDC environment not configured") + } + + // Parse the request URL + u, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("failed to parse ACTIONS_ID_TOKEN_REQUEST_URL: %w", err) + } + + // Add the audience parameter + q := u.Query() + q.Set("audience", audience) + u.RawQuery = q.Encode() + + // Create HTTP request with context + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", requestToken)) + + // Execute request with timeout + client := &http.Client{Timeout: 30 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch token: %w", err) + } + defer resp.Body.Close() + + // Check response status + if resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("failed to get OIDC token, status: %d, body: %s", + resp.StatusCode, string(bodyBytes)) + } + + // Parse response + var payload struct { + Value string `json:"value"` + } + if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { + return "", fmt.Errorf("failed to decode response: %w", err) + } + + if payload.Value == "" { + return "", fmt.Errorf("received empty token from GitHub OIDC") + } + + return payload.Value, nil +} + +// getEnvOrDefault returns environment variable value or default +func getEnvOrDefault(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} diff --git a/pkg/leeway/signing/attestation_test.go b/pkg/leeway/signing/attestation_test.go index c91005e..1615e56 100644 --- a/pkg/leeway/signing/attestation_test.go +++ b/pkg/leeway/signing/attestation_test.go @@ -6,8 +6,11 @@ import ( "encoding/hex" "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" "github.com/gitpod-io/leeway/pkg/leeway/cache" @@ -1088,3 +1091,118 @@ func TestSignProvenanceWithSigstore_EnvironmentValidation(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "failed to sign SLSA provenance") } + +func TestFetchGitHubOIDCToken(t *testing.T) { + tests := []struct { + name string + setupEnv func(*testing.T) + mockServer func(*testing.T) *httptest.Server + audience string + expectError bool + errorContains string + }{ + { + name: "successful token fetch", + setupEnv: func(t *testing.T) { + // Will be set by mockServer + }, + mockServer: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Verify audience parameter + if r.URL.Query().Get("audience") != "sigstore" { + t.Errorf("Expected audience=sigstore, got %s", r.URL.Query().Get("audience")) + } + // Verify Authorization header + if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") { + t.Error("Missing or invalid Authorization header") + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"value": "test-token-12345"}) + })) + }, + audience: "sigstore", + expectError: false, + }, + { + name: "missing environment variables", + setupEnv: func(t *testing.T) { + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + }, + audience: "sigstore", + expectError: true, + errorContains: "GitHub OIDC environment not configured", + }, + { + name: "HTTP 500 error", + mockServer: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte(`{"error": "internal error"}`)) + })) + }, + audience: "sigstore", + expectError: true, + errorContains: "status: 500", + }, + { + name: "empty token in response", + mockServer: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{"value": ""}) + })) + }, + audience: "sigstore", + expectError: true, + errorContains: "received empty token", + }, + { + name: "invalid JSON response", + mockServer: func(t *testing.T) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(`invalid json`)) + })) + }, + audience: "sigstore", + expectError: true, + errorContains: "failed to decode response", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + if tt.setupEnv != nil { + tt.setupEnv(t) + } + + var server *httptest.Server + if tt.mockServer != nil { + server = tt.mockServer(t) + defer server.Close() + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", server.URL) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-request-token") + } + + // Execute + ctx := context.Background() + token, err := fetchGitHubOIDCToken(ctx, tt.audience) + + // Verify + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.NotEmpty(t, token) + if server != nil { + assert.Equal(t, "test-token-12345", token) + } + } + }) + } +} From 73245f64349107b59cd1da06c53151e5a082cb5d Mon Sep 17 00:00:00 2001 From: Leo Di Donato <120051+leodido@users.noreply.github.com> Date: Fri, 24 Oct 2025 20:05:59 +0000 Subject: [PATCH 2/2] fix(lint): remove unused function and fix unchecked error returns - Remove unused getEnvOrDefault function - Add blank identifier assignments for unchecked error returns in test file Co-authored-by: Ona --- pkg/leeway/signing/attestation.go | 8 -------- pkg/leeway/signing/attestation_test.go | 8 ++++---- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/pkg/leeway/signing/attestation.go b/pkg/leeway/signing/attestation.go index bf74a0b..c4c718d 100644 --- a/pkg/leeway/signing/attestation.go +++ b/pkg/leeway/signing/attestation.go @@ -426,11 +426,3 @@ func fetchGitHubOIDCToken(ctx context.Context, audience string) (string, error) return payload.Value, nil } - -// getEnvOrDefault returns environment variable value or default -func getEnvOrDefault(key, defaultValue string) string { - if value := os.Getenv(key); value != "" { - return value - } - return defaultValue -} diff --git a/pkg/leeway/signing/attestation_test.go b/pkg/leeway/signing/attestation_test.go index 1615e56..b121231 100644 --- a/pkg/leeway/signing/attestation_test.go +++ b/pkg/leeway/signing/attestation_test.go @@ -1117,7 +1117,7 @@ func TestFetchGitHubOIDCToken(t *testing.T) { t.Error("Missing or invalid Authorization header") } w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"value": "test-token-12345"}) + _ = json.NewEncoder(w).Encode(map[string]string{"value": "test-token-12345"}) })) }, audience: "sigstore", @@ -1138,7 +1138,7 @@ func TestFetchGitHubOIDCToken(t *testing.T) { mockServer: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte(`{"error": "internal error"}`)) + _, _ = w.Write([]byte(`{"error": "internal error"}`)) })) }, audience: "sigstore", @@ -1150,7 +1150,7 @@ func TestFetchGitHubOIDCToken(t *testing.T) { mockServer: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(map[string]string{"value": ""}) + _ = json.NewEncoder(w).Encode(map[string]string{"value": ""}) })) }, audience: "sigstore", @@ -1162,7 +1162,7 @@ func TestFetchGitHubOIDCToken(t *testing.T) { mockServer: func(t *testing.T) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) - w.Write([]byte(`invalid json`)) + _, _ = w.Write([]byte(`invalid json`)) })) }, audience: "sigstore",