From 8978c5ed0047885df59bc9998f8f6d7d9a17bd05 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] 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 | 76 +++++++++++++++- pkg/leeway/signing/attestation_test.go | 120 ++++++++++++++++++++++++- 2 files changed, 193 insertions(+), 3 deletions(-) diff --git a/pkg/leeway/signing/attestation.go b/pkg/leeway/signing/attestation.go index adba379..eefe934 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" @@ -246,6 +248,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 { @@ -264,8 +277,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 @@ -356,6 +368,66 @@ func validateSigstoreEnvironment() error { 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 != "" { diff --git a/pkg/leeway/signing/attestation_test.go b/pkg/leeway/signing/attestation_test.go index 20ee6c0..dc7da46 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/stretchr/testify/assert" @@ -1138,4 +1141,119 @@ func TestSignProvenanceWithSigstore_EnvironmentValidation(t *testing.T) { _, err := GenerateSignedSLSAAttestation(context.Background(), artifactPath, githubCtx) assert.Error(t, err) assert.Contains(t, err.Error(), "failed to sign SLSA provenance") -} \ No newline at end of file +} + +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) + } + } + }) + } +}