diff --git a/pkg/leeway/signing/attestation.go b/pkg/leeway/signing/attestation.go index c4c718d1..15ee61b2 100644 --- a/pkg/leeway/signing/attestation.go +++ b/pkg/leeway/signing/attestation.go @@ -3,6 +3,7 @@ package signing import ( "context" "crypto/sha256" + "encoding/base64" "encoding/json" "fmt" "io" @@ -10,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "time" "github.com/in-toto/in-toto-golang/in_toto" @@ -88,7 +90,13 @@ func GenerateSignedSLSAAttestation(ctx context.Context, artifactPath string, git } sourceURI := fmt.Sprintf("%s/%s", githubCtx.ServerURL, githubCtx.Repository) - builderID := fmt.Sprintf("%s/%s", githubCtx.ServerURL, githubCtx.WorkflowRef) + + // Extract builder ID from OIDC token to match certificate identity + // This is critical for compatibility with reusable workflows + builderID, err := extractBuilderIDFromOIDC(ctx, githubCtx) + if err != nil { + return nil, fmt.Errorf("failed to extract builder ID from OIDC token: %w", err) + } log.WithFields(log.Fields{ "artifact": filepath.Base(artifactPath), @@ -367,6 +375,109 @@ func validateSigstoreEnvironment() error { return nil } +// extractBuilderIDFromOIDC extracts the builder ID from the GitHub OIDC token. +// This ensures the builder ID matches the certificate identity issued by Fulcio, which is +// critical for slsa-verifier compatibility, especially with reusable workflows. +// +// For reusable workflows, the OIDC token contains: +// - job_workflow_ref claim: Points to the actual executing workflow (e.g., _build.yml) +// - sub claim: May contain job_workflow_ref embedded in colon-separated format +// - workflow_ref env var: Points to the calling workflow (e.g., build-main.yml) +// +// Fulcio uses the sub claim for the certificate identity. For reusable workflows, +// the sub claim includes job_workflow_ref in the format: +// repo:OWNER/REPO:ref:REF:job_workflow_ref:OWNER/REPO/.github/workflows/WORKFLOW@REF +func extractBuilderIDFromOIDC(ctx context.Context, githubCtx *GitHubContext) (string, error) { + // Fetch the OIDC token with sigstore audience + idToken, err := fetchGitHubOIDCToken(ctx, "sigstore") + if err != nil { + return "", fmt.Errorf("failed to fetch OIDC token: %w", err) + } + + // Parse the JWT token to extract claims + // JWT format: header.payload.signature + parts := strings.Split(idToken, ".") + if len(parts) != 3 { + return "", fmt.Errorf("invalid JWT token format: expected 3 parts, got %d", len(parts)) + } + + // Decode the payload (second part) + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return "", fmt.Errorf("failed to decode JWT payload: %w", err) + } + + // Parse the payload JSON + var claims map[string]interface{} + if err := json.Unmarshal(payloadBytes, &claims); err != nil { + return "", fmt.Errorf("failed to parse JWT claims: %w", err) + } + + // Extract the sub claim (required for Fulcio certificate identity) + sub, ok := claims["sub"].(string) + if !ok || strings.TrimSpace(sub) == "" { + return "", fmt.Errorf("sub claim not found or empty in OIDC token") + } + + // Try to extract job_workflow_ref from the sub claim first + // + // Context: + // When we call sign.Bundle() with the OIDC token, the Sigstore library sends it to Fulcio (Sigstore's CA). + // Fulcio extracts claims from the OIDC token and issues a short-lived certificate with the builder identity in the Subject Alternative Name (SAN). + // For verification to succeed, our attestation's builder ID must match what Fulcio puts in the certificate SAN. + // + // TODO: Verify if GitHub embeds job_workflow_ref in the sub claim or only provides it as top-level. + // GitHub docs show it as top-level, but we need to confirm what Fulcio actually uses. The current + // implementation tries both approaches to ensure we match Fulcio's extraction logic. + jobWorkflowRef := extractJobWorkflowRef(sub) + + // If not found in sub, try the top-level job_workflow_ref claim + if jobWorkflowRef == "" { + if jwfRef, ok := claims["job_workflow_ref"].(string); ok && jwfRef != "" { + jobWorkflowRef = jwfRef + log.WithField("job_workflow_ref", jobWorkflowRef).Debug("Using top-level job_workflow_ref claim (not found in sub)") + } + } + + if jobWorkflowRef == "" { + return "", fmt.Errorf("job_workflow_ref not found in sub claim or top-level claims: %s", sub) + } + + // Construct the builder ID URL + builderID := fmt.Sprintf("%s/%s", githubCtx.ServerURL, jobWorkflowRef) + + log.WithFields(log.Fields{ + "sub_claim": sub, + "job_workflow_ref": jobWorkflowRef, + "builder_id": builderID, + }).Debug("Extracted builder ID from OIDC token") + + return builderID, nil +} + +// extractJobWorkflowRef extracts the job_workflow_ref from a GitHub OIDC sub claim. +// The sub claim format for reusable workflows is: +// repo:OWNER/REPO:ref:REF:job_workflow_ref:OWNER/REPO/.github/workflows/WORKFLOW@REF +// +// For direct workflows (non-reusable), the format is similar but job_workflow_ref +// points to the same workflow as workflow_ref. +func extractJobWorkflowRef(sub string) string { + // Split by colon to parse the structured claim + parts := strings.Split(sub, ":") + + // Find the job_workflow_ref field + for i, part := range parts { + if part == "job_workflow_ref" && i+1 < len(parts) { + // Return everything after "job_workflow_ref:" + // This handles the case where the workflow path contains colons + return strings.Join(parts[i+1:], ":") + } + } + + // If no job_workflow_ref found, return empty string + return "" +} + // 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. @@ -403,7 +514,7 @@ func fetchGitHubOIDCToken(ctx context.Context, audience string) (string, error) if err != nil { return "", fmt.Errorf("failed to fetch token: %w", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Check response status if resp.StatusCode != http.StatusOK { diff --git a/pkg/leeway/signing/attestation_test.go b/pkg/leeway/signing/attestation_test.go index b1212313..f8d8871e 100644 --- a/pkg/leeway/signing/attestation_test.go +++ b/pkg/leeway/signing/attestation_test.go @@ -3,6 +3,7 @@ package signing import ( "context" "crypto/sha256" + "encoding/base64" "encoding/hex" "encoding/json" "fmt" @@ -14,6 +15,7 @@ import ( "testing" "github.com/gitpod-io/leeway/pkg/leeway/cache" + "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -598,12 +600,12 @@ func TestGenerateSignedSLSAAttestation_Integration(t *testing.T) { githubCtx := createMockGitHubContext() // Test that the function exists and has the right signature - // We expect it to fail due to missing Sigstore environment, but that's expected + // We expect it to fail due to missing OIDC environment (strict mode) _, err := GenerateSignedSLSAAttestation(context.Background(), artifactPath, githubCtx) - // We expect an error related to Sigstore/signing, not basic validation + // We expect an error related to OIDC extraction (fails fast before signing) assert.Error(t, err) - assert.Contains(t, err.Error(), "sign", "Error should be related to signing process") + assert.Contains(t, err.Error(), "failed to extract builder ID from OIDC token", "Error should be related to OIDC extraction") } // TestSignedAttestationResult_Structure tests the result structure @@ -629,105 +631,62 @@ func TestSignedAttestationResult_Structure(t *testing.T) { // TestGetGitHubContext tests the environment variable extraction func TestGetGitHubContext(t *testing.T) { - // Save original environment - originalEnv := map[string]string{ - "GITHUB_RUN_ID": os.Getenv("GITHUB_RUN_ID"), - "GITHUB_RUN_NUMBER": os.Getenv("GITHUB_RUN_NUMBER"), - "GITHUB_ACTOR": os.Getenv("GITHUB_ACTOR"), - "GITHUB_REPOSITORY": os.Getenv("GITHUB_REPOSITORY"), - "GITHUB_REF": os.Getenv("GITHUB_REF"), - "GITHUB_SHA": os.Getenv("GITHUB_SHA"), - "GITHUB_SERVER_URL": os.Getenv("GITHUB_SERVER_URL"), - "GITHUB_WORKFLOW_REF": os.Getenv("GITHUB_WORKFLOW_REF"), - } + // Set test environment (t.Setenv automatically handles cleanup) + t.Setenv("GITHUB_RUN_ID", "test-run-id") + t.Setenv("GITHUB_RUN_NUMBER", "test-run-number") + t.Setenv("GITHUB_ACTOR", "test-actor") + t.Setenv("GITHUB_REPOSITORY", "test-repo") + t.Setenv("GITHUB_REF", "test-ref") + t.Setenv("GITHUB_SHA", "test-sha") + t.Setenv("GITHUB_SERVER_URL", "test-server") + t.Setenv("GITHUB_WORKFLOW_REF", "test-workflow") - // Clean up after test - defer func() { - for k, v := range originalEnv { - if v == "" { - _ = os.Unsetenv(k) - } else { - _ = os.Setenv(k, v) - } - } - }() - - // Set test environment - testEnv := map[string]string{ - "GITHUB_RUN_ID": "test-run-id", - "GITHUB_RUN_NUMBER": "test-run-number", - "GITHUB_ACTOR": "test-actor", - "GITHUB_REPOSITORY": "test-repo", - "GITHUB_REF": "test-ref", - "GITHUB_SHA": "test-sha", - "GITHUB_SERVER_URL": "test-server", - "GITHUB_WORKFLOW_REF": "test-workflow", + // Test GetGitHubContext + got := GetGitHubContext() + want := &GitHubContext{ + RunID: "test-run-id", + RunNumber: "test-run-number", + Actor: "test-actor", + Repository: "test-repo", + Ref: "test-ref", + SHA: "test-sha", + ServerURL: "test-server", + WorkflowRef: "test-workflow", } - for k, v := range testEnv { - _ = os.Setenv(k, v) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("GetGitHubContext() mismatch (-want +got):\n%s", diff) } - - // Test GetGitHubContext - ctx := GetGitHubContext() - - assert.Equal(t, testEnv["GITHUB_RUN_ID"], ctx.RunID) - assert.Equal(t, testEnv["GITHUB_RUN_NUMBER"], ctx.RunNumber) - assert.Equal(t, testEnv["GITHUB_ACTOR"], ctx.Actor) - assert.Equal(t, testEnv["GITHUB_REPOSITORY"], ctx.Repository) - assert.Equal(t, testEnv["GITHUB_REF"], ctx.Ref) - assert.Equal(t, testEnv["GITHUB_SHA"], ctx.SHA) - assert.Equal(t, testEnv["GITHUB_SERVER_URL"], ctx.ServerURL) - assert.Equal(t, testEnv["GITHUB_WORKFLOW_REF"], ctx.WorkflowRef) } // TestGetGitHubContext_EmptyEnvironment tests with empty environment func TestGetGitHubContext_EmptyEnvironment(t *testing.T) { - // Save original environment - originalEnv := map[string]string{ - "GITHUB_RUN_ID": os.Getenv("GITHUB_RUN_ID"), - "GITHUB_RUN_NUMBER": os.Getenv("GITHUB_RUN_NUMBER"), - "GITHUB_ACTOR": os.Getenv("GITHUB_ACTOR"), - "GITHUB_REPOSITORY": os.Getenv("GITHUB_REPOSITORY"), - "GITHUB_REF": os.Getenv("GITHUB_REF"), - "GITHUB_SHA": os.Getenv("GITHUB_SHA"), - "GITHUB_SERVER_URL": os.Getenv("GITHUB_SERVER_URL"), - "GITHUB_WORKFLOW_REF": os.Getenv("GITHUB_WORKFLOW_REF"), - } - - // Clean up after test - defer func() { - for k, v := range originalEnv { - if v == "" { - _ = os.Unsetenv(k) - } else { - _ = os.Setenv(k, v) - } - } - }() + // Clear all GitHub environment variables (t.Setenv automatically handles cleanup) + t.Setenv("GITHUB_RUN_ID", "") + t.Setenv("GITHUB_RUN_NUMBER", "") + t.Setenv("GITHUB_ACTOR", "") + t.Setenv("GITHUB_REPOSITORY", "") + t.Setenv("GITHUB_REF", "") + t.Setenv("GITHUB_SHA", "") + t.Setenv("GITHUB_SERVER_URL", "") + t.Setenv("GITHUB_WORKFLOW_REF", "") - // Clear all GitHub environment variables - githubVars := []string{ - "GITHUB_RUN_ID", "GITHUB_RUN_NUMBER", "GITHUB_ACTOR", - "GITHUB_REPOSITORY", "GITHUB_REF", "GITHUB_SHA", - "GITHUB_SERVER_URL", "GITHUB_WORKFLOW_REF", + // Test GetGitHubContext with empty environment + got := GetGitHubContext() + want := &GitHubContext{ + RunID: "", + RunNumber: "", + Actor: "", + Repository: "", + Ref: "", + SHA: "", + ServerURL: "", + WorkflowRef: "", } - for _, v := range githubVars { - _ = os.Unsetenv(v) + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("GetGitHubContext() with empty env mismatch (-want +got):\n%s", diff) } - - // Test GetGitHubContext with empty environment - ctx := GetGitHubContext() - - assert.Empty(t, ctx.RunID) - assert.Empty(t, ctx.RunNumber) - assert.Empty(t, ctx.Actor) - assert.Empty(t, ctx.Repository) - assert.Empty(t, ctx.Ref) - assert.Empty(t, ctx.SHA) - assert.Empty(t, ctx.ServerURL) - assert.Empty(t, ctx.WorkflowRef) } // TestSigningError tests the error types @@ -917,29 +876,11 @@ func (m *mockRemoteCache) UploadFile(ctx context.Context, filePath string, key s // TestGetEnvOrDefault tests the environment variable helper // TestValidateSigstoreEnvironment tests Sigstore environment validation func TestValidateSigstoreEnvironment(t *testing.T) { - // Save original environment - originalEnv := map[string]string{ - "ACTIONS_ID_TOKEN_REQUEST_TOKEN": os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"), - "ACTIONS_ID_TOKEN_REQUEST_URL": os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"), - "GITHUB_ACTIONS": os.Getenv("GITHUB_ACTIONS"), - } - - // Clean up after test - defer func() { - for k, v := range originalEnv { - if v == "" { - _ = os.Unsetenv(k) - } else { - _ = os.Setenv(k, v) - } - } - }() - t.Run("missing required environment", func(t *testing.T) { - // Clear all Sigstore environment variables - _ = os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") - _ = os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_URL") - _ = os.Unsetenv("GITHUB_ACTIONS") + // Clear all Sigstore environment variables (t.Setenv automatically handles cleanup) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + t.Setenv("GITHUB_ACTIONS", "") err := validateSigstoreEnvironment() assert.Error(t, err) @@ -947,20 +888,20 @@ func TestValidateSigstoreEnvironment(t *testing.T) { }) t.Run("partial environment", func(t *testing.T) { - // Set some but not all required variables - _ = os.Setenv("GITHUB_ACTIONS", "true") - _ = os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") - _ = os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_URL") + // Set some but not all required variables (t.Setenv automatically handles cleanup) + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") err := validateSigstoreEnvironment() assert.Error(t, err) }) t.Run("complete environment", func(t *testing.T) { - // Set all required variables - _ = os.Setenv("GITHUB_ACTIONS", "true") - _ = os.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-token") - _ = os.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://test.url") + // Set all required variables (t.Setenv automatically handles cleanup) + t.Setenv("GITHUB_ACTIONS", "true") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-token") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://test.url") err := validateSigstoreEnvironment() assert.NoError(t, err) @@ -997,12 +938,12 @@ func TestCategorizeError_ExistingSigningError(t *testing.T) { Message: "access denied", } - result := CategorizeError("different.tar.gz", originalErr) + got := CategorizeError("different.tar.gz", originalErr) // Should return the original error unchanged - assert.Equal(t, originalErr, result) - assert.Equal(t, ErrorTypePermission, result.Type) - assert.Equal(t, "test.tar.gz", result.Artifact) // Original artifact preserved + if diff := cmp.Diff(originalErr, got); diff != "" { + t.Errorf("CategorizeError() should preserve original error (-want +got):\n%s", diff) + } } // TestWithRetry_MaxAttemptsExceeded tests retry exhaustion @@ -1060,36 +1001,18 @@ func TestGenerateSignedSLSAAttestation_InvalidContext(t *testing.T) { // TestSignProvenanceWithSigstore_EnvironmentValidation tests Sigstore environment validation func TestSignProvenanceWithSigstore_EnvironmentValidation(t *testing.T) { - // Save original environment - originalEnv := map[string]string{ - "ACTIONS_ID_TOKEN_REQUEST_TOKEN": os.Getenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN"), - "ACTIONS_ID_TOKEN_REQUEST_URL": os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL"), - "GITHUB_ACTIONS": os.Getenv("GITHUB_ACTIONS"), - } - - // Clean up after test - defer func() { - for k, v := range originalEnv { - if v == "" { - _ = os.Unsetenv(k) - } else { - _ = os.Setenv(k, v) - } - } - }() - - // Clear Sigstore environment to trigger validation error - _ = os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN") - _ = os.Unsetenv("ACTIONS_ID_TOKEN_REQUEST_URL") - _ = os.Unsetenv("GITHUB_ACTIONS") + // Clear Sigstore environment to trigger validation error (t.Setenv automatically handles cleanup) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "") + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "") + t.Setenv("GITHUB_ACTIONS", "") artifactPath := createTestArtifact(t, "test content") githubCtx := createMockGitHubContext() - // This should fail at Sigstore environment validation + // This should fail at OIDC extraction (strict mode - fails fast) _, err := GenerateSignedSLSAAttestation(context.Background(), artifactPath, githubCtx) assert.Error(t, err) - assert.Contains(t, err.Error(), "failed to sign SLSA provenance") + assert.Contains(t, err.Error(), "failed to extract builder ID from OIDC token") } func TestFetchGitHubOIDCToken(t *testing.T) { @@ -1117,7 +1040,9 @@ 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"}) + if err := json.NewEncoder(w).Encode(map[string]string{"value": "test-token-12345"}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } })) }, audience: "sigstore", @@ -1150,7 +1075,9 @@ 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": ""}) + if err := json.NewEncoder(w).Encode(map[string]string{"value": ""}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } })) }, audience: "sigstore", @@ -1206,3 +1133,416 @@ func TestFetchGitHubOIDCToken(t *testing.T) { }) } } + +// TestExtractJobWorkflowRef tests the extraction of job_workflow_ref from OIDC sub claims +func TestExtractJobWorkflowRef(t *testing.T) { + tests := []struct { + name string + subClaim string + expected string + }{ + { + name: "reusable workflow with job_workflow_ref", + subClaim: "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b", + expected: "example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b", + }, + { + name: "direct workflow (non-reusable)", + subClaim: "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main", + expected: "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main", + }, + { + name: "workflow path with colons", + subClaim: "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/build:test.yml@refs/heads/main", + expected: "org/repo/.github/workflows/build:test.yml@refs/heads/main", + }, + { + name: "multiple colons in workflow path", + subClaim: "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/test:build:deploy.yml@refs/heads/main", + expected: "org/repo/.github/workflows/test:build:deploy.yml@refs/heads/main", + }, + { + name: "reusable workflow with environment claim", + subClaim: "repo:gitpod-io/gitpod:environment:production:ref:refs/heads/main:job_workflow_ref:gitpod-io/gitpod/.github/workflows/_build-image.yml@refs/heads/main", + expected: "gitpod-io/gitpod/.github/workflows/_build-image.yml@refs/heads/main", + }, + { + name: "pull request workflow", + subClaim: "repo:gitpod-io/leeway:ref:refs/pull/264/merge:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/pull/264/merge", + expected: "gitpod-io/leeway/.github/workflows/build.yml@refs/pull/264/merge", + }, + { + name: "tag-triggered workflow", + subClaim: "repo:org/repo:ref:refs/tags/v1.0.0:job_workflow_ref:org/repo/.github/workflows/release.yml@refs/tags/v1.0.0", + expected: "org/repo/.github/workflows/release.yml@refs/tags/v1.0.0", + }, + { + name: "missing job_workflow_ref", + subClaim: "repo:example-org/example-repo:ref:refs/heads/main", + expected: "", + }, + { + name: "empty sub claim", + subClaim: "", + expected: "", + }, + { + name: "malformed sub claim", + subClaim: "invalid:format", + expected: "", + }, + { + name: "job_workflow_ref at end without value", + subClaim: "repo:org/repo:ref:refs/heads/main:job_workflow_ref:", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := extractJobWorkflowRef(tt.subClaim) + assert.Equal(t, tt.expected, result) + }) + } +} + +// Helper function to create base64url encoded strings for JWT tokens +func base64EncodeForTest(s string) string { + return strings.TrimRight(base64.URLEncoding.EncodeToString([]byte(s)), "=") +} + +// TestExtractBuilderIDFromOIDC tests the extraction of builder ID from OIDC tokens +func TestExtractBuilderIDFromOIDC(t *testing.T) { + tests := []struct { + name string + setupServer func() *httptest.Server + githubCtx *GitHubContext + want struct { + id string + err string + } + }{ + { + name: "valid OIDC token with reusable workflow", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`) + payload := base64EncodeForTest(`{ + "sub": "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b", + "aud": "sigstore", + "iss": "https://token.actions.githubusercontent.com" + }`) + signature := base64EncodeForTest("fake-signature") + token := fmt.Sprintf("%s.%s.%s", header, payload, signature) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"value": token}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + }, + githubCtx: &GitHubContext{ + ServerURL: "https://github.com", + Repository: "example-org/example-repo", + WorkflowRef: "example-org/example-repo/.github/workflows/calling-workflow.yml@refs/heads/main", + }, + want: struct { + id string + err string + }{ + id: "https://github.com/example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b", + }, + }, + { + name: "valid OIDC token with direct workflow", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`) + payload := base64EncodeForTest(`{ + "sub": "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main", + "aud": "sigstore" + }`) + signature := base64EncodeForTest("fake-signature") + token := fmt.Sprintf("%s.%s.%s", header, payload, signature) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"value": token}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + }, + githubCtx: &GitHubContext{ + ServerURL: "https://github.com", + Repository: "gitpod-io/leeway", + WorkflowRef: "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main", + }, + want: struct { + id string + err string + }{ + id: "https://github.com/gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main", + }, + }, + { + name: "invalid JWT format - only 2 parts", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := "header.payload" + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"value": token}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + }, + githubCtx: &GitHubContext{ + ServerURL: "https://github.com", + Repository: "org/repo", + }, + want: struct { + id string + err string + }{ + err: "invalid JWT token format", + }, + }, + { + name: "missing sub claim", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`) + payload := base64EncodeForTest(`{"aud": "sigstore"}`) + signature := base64EncodeForTest("fake-signature") + token := fmt.Sprintf("%s.%s.%s", header, payload, signature) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"value": token}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + }, + githubCtx: &GitHubContext{ + ServerURL: "https://github.com", + Repository: "org/repo", + }, + want: struct { + id string + err string + }{ + err: "sub claim not found", + }, + }, + { + name: "whitespace-only sub claim", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`) + payload := base64EncodeForTest(`{"sub": " ", "aud": "sigstore"}`) + signature := base64EncodeForTest("fake-signature") + token := fmt.Sprintf("%s.%s.%s", header, payload, signature) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"value": token}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + }, + githubCtx: &GitHubContext{ + ServerURL: "https://github.com", + Repository: "org/repo", + }, + want: struct { + id string + err string + }{ + err: "sub claim not found or empty", + }, + }, + { + name: "job_workflow_ref in top-level claim (not in sub)", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`) + payload := base64EncodeForTest(`{ + "sub": "repo:org/repo:environment:prod", + "aud": "sigstore", + "job_workflow_ref": "org/repo/.github/workflows/deploy.yml@refs/heads/main" + }`) + signature := base64EncodeForTest("fake-signature") + token := fmt.Sprintf("%s.%s.%s", header, payload, signature) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"value": token}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + }, + githubCtx: &GitHubContext{ + ServerURL: "https://github.com", + Repository: "org/repo", + }, + want: struct { + id string + err string + }{ + id: "https://github.com/org/repo/.github/workflows/deploy.yml@refs/heads/main", + }, + }, + { + name: "missing job_workflow_ref in sub claim and top-level", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`) + payload := base64EncodeForTest(`{ + "sub": "repo:org/repo:ref:refs/heads/main", + "aud": "sigstore" + }`) + signature := base64EncodeForTest("fake-signature") + token := fmt.Sprintf("%s.%s.%s", header, payload, signature) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"value": token}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + }, + githubCtx: &GitHubContext{ + ServerURL: "https://github.com", + Repository: "org/repo", + }, + want: struct { + id string + err string + }{ + err: "job_workflow_ref not found", + }, + }, + { + name: "OIDC token fetch failure", + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + }, + githubCtx: &GitHubContext{ + ServerURL: "https://github.com", + Repository: "org/repo", + }, + want: struct { + id string + err string + }{ + err: "failed to fetch OIDC token", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := tt.setupServer() + defer server.Close() + + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", server.URL) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-token") + + builderID, err := extractBuilderIDFromOIDC(context.Background(), tt.githubCtx) + + if tt.want.err != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.want.err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.want.id, builderID) + } + }) + } +} + +// TestBuilderIDMatchesCertificateIdentity is the critical regression test +// It verifies that the builder ID extracted from OIDC matches what Fulcio +// would use for the certificate identity, preventing verification failures +func TestBuilderIDMatchesCertificateIdentity(t *testing.T) { + tests := []struct { + name string + oidcSubClaim string + githubWorkflowRef string + expectedBuilderID string + shouldMatchWorkflowRef bool + description string + }{ + { + name: "reusable workflow - builder ID must match OIDC not GITHUB_WORKFLOW_REF", + oidcSubClaim: "repo:example-org/example-repo:ref:refs/heads/main:job_workflow_ref:example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b", + githubWorkflowRef: "example-org/example-repo/.github/workflows/calling-workflow.yml@refs/heads/main", + expectedBuilderID: "https://github.com/example-org/example-repo/.github/workflows/_build.yml@refs/heads/leo/slsa/b", + shouldMatchWorkflowRef: false, + description: "For reusable workflows, certificate identity comes from OIDC sub claim (actual executing workflow), not GITHUB_WORKFLOW_REF (calling workflow)", + }, + { + name: "direct workflow - builder ID matches both OIDC and GITHUB_WORKFLOW_REF", + oidcSubClaim: "repo:gitpod-io/leeway:ref:refs/heads/main:job_workflow_ref:gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main", + githubWorkflowRef: "gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main", + expectedBuilderID: "https://github.com/gitpod-io/leeway/.github/workflows/build.yml@refs/heads/main", + shouldMatchWorkflowRef: true, + description: "For direct workflows, OIDC and GITHUB_WORKFLOW_REF point to the same workflow", + }, + { + name: "nested reusable workflow", + oidcSubClaim: "repo:org/repo:ref:refs/heads/main:job_workflow_ref:org/repo/.github/workflows/_internal-build.yml@refs/heads/feature", + githubWorkflowRef: "org/repo/.github/workflows/main-workflow.yml@refs/heads/main", + expectedBuilderID: "https://github.com/org/repo/.github/workflows/_internal-build.yml@refs/heads/feature", + shouldMatchWorkflowRef: false, + description: "Nested reusable workflows also use OIDC sub claim for certificate identity", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + header := base64EncodeForTest(`{"alg":"RS256","typ":"JWT"}`) + payload := base64EncodeForTest(fmt.Sprintf(`{ + "sub": "%s", + "aud": "sigstore", + "iss": "https://token.actions.githubusercontent.com" + }`, tt.oidcSubClaim)) + signature := base64EncodeForTest("fake-signature") + token := fmt.Sprintf("%s.%s.%s", header, payload, signature) + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]string{"value": token}); err != nil { + t.Errorf("Failed to encode response: %v", err) + } + })) + defer server.Close() + + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", server.URL) + t.Setenv("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "test-token") + + githubCtx := &GitHubContext{ + ServerURL: "https://github.com", + Repository: "example-org/example-repo", + WorkflowRef: tt.githubWorkflowRef, + } + + builderID, err := extractBuilderIDFromOIDC(context.Background(), githubCtx) + require.NoError(t, err, tt.description) + + assert.Equal(t, tt.expectedBuilderID, builderID, tt.description) + + workflowRefBasedID := fmt.Sprintf("%s/%s", githubCtx.ServerURL, tt.githubWorkflowRef) + if tt.shouldMatchWorkflowRef { + assert.Equal(t, workflowRefBasedID, builderID, + "For direct workflows, builder ID should match GITHUB_WORKFLOW_REF-based ID") + } else { + assert.NotEqual(t, workflowRefBasedID, builderID, + "For reusable workflows, builder ID must NOT match GITHUB_WORKFLOW_REF-based ID - this is the critical fix") + } + + jobWorkflowRef := extractJobWorkflowRef(tt.oidcSubClaim) + require.NotEmpty(t, jobWorkflowRef, "job_workflow_ref should be extractable from sub claim") + + expectedFromJobWorkflowRef := fmt.Sprintf("%s/%s", githubCtx.ServerURL, jobWorkflowRef) + assert.Equal(t, expectedFromJobWorkflowRef, builderID, + "Builder ID must be constructed from OIDC job_workflow_ref to match Fulcio certificate identity") + }) + } +}