Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 74 additions & 2 deletions pkg/leeway/signing/attestation.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"time"
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand Down
120 changes: 119 additions & 1 deletion pkg/leeway/signing/attestation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}
}

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)
}
}
})
}
}
Loading