From 4ca261d02b18acd8c2566984d11f5debe45804ba Mon Sep 17 00:00:00 2001 From: Nandaja Date: Tue, 25 Aug 2026 15:09:34 +0000 Subject: [PATCH] fix(cache): verify attestation identity Co-authored-by: Codex --- README.md | 11 +++ cmd/build.go | 8 ++ cmd/build_test.go | 36 ++++++++ cmd/root.go | 3 + pkg/leeway/cache/remote/s3.go | 3 +- pkg/leeway/cache/slsa/verifier.go | 97 ++++++++++++++++++-- pkg/leeway/cache/slsa/verifier_test.go | 118 +++++++++++++++++++++++++ pkg/leeway/cache/types.go | 3 + 8 files changed, 273 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index ff3c39c9..fa5b28d8 100644 --- a/README.md +++ b/README.md @@ -660,6 +660,17 @@ These features are automatically enabled by setting environment variables: - `LEEWAY_DOCKER_EXPORT_TO_CACHE=true` - `LEEWAY_SLSA_SOURCE_URI` (set from Git origin) +Cache verification requires a certificate issued by GitHub Actions for the +configured source repository. Trusted branch consumers should also set +`LEEWAY_SLSA_SOURCE_REF` (or `--slsa-source-ref`) to an exact ref. For example: + +```bash +export LEEWAY_SLSA_SOURCE_REF=refs/heads/main +``` + +An attestation signed by a different issuer, repository, or configured ref is +treated as invalid. In strict mode, Leeway rebuilds the package locally. + ### SLSA Cache Verification Modes When cache verification is enabled, Leeway can operate in two modes: diff --git a/cmd/build.go b/cmd/build.go index 415827cb..4e3e5972 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -234,6 +234,7 @@ func addBuildFlags(cmd *cobra.Command) { cmd.Flags().StringToString("docker-build-options", nil, "Options passed to all 'docker build' commands") cmd.Flags().Bool("slsa-cache-verification", false, "Enable SLSA verification for cached artifacts") cmd.Flags().String("slsa-source-uri", "", "Expected source URI for SLSA verification (required when verification enabled)") + cmd.Flags().String("slsa-source-ref", "", "Expected source ref for SLSA verification") cmd.Flags().Bool("slsa-require-attestation", false, "Require SLSA attestations (missing/invalid → build locally)") cmd.Flags().Bool("in-flight-checksums", false, "Enable checksumming of cache artifacts to prevent TOCTU attacks") cmd.Flags().String("report", "", "Generate a HTML report after the build has finished. (e.g. --report myreport.html)") @@ -495,6 +496,7 @@ func parseSLSAConfig(cmd *cobra.Command) (*cache.SLSAConfig, error) { // Get SLSA verification settings from environment variables (defaults) slsaVerificationEnabled := os.Getenv(EnvvarSLSACacheVerification) == "true" slsaSourceURI := os.Getenv(EnvvarSLSASourceURI) + slsaSourceRef := os.Getenv(EnvvarSLSASourceRef) requireAttestation := os.Getenv(EnvvarSLSARequireAttestation) == "true" // CLI flags override environment variables (if cmd is provided) @@ -509,6 +511,11 @@ func parseSLSAConfig(cmd *cobra.Command) (*cache.SLSAConfig, error) { slsaSourceURI = flagValue } } + if cmd.Flags().Changed("slsa-source-ref") { + if flagValue, err := cmd.Flags().GetString("slsa-source-ref"); err == nil { + slsaSourceRef = flagValue + } + } if cmd.Flags().Changed("slsa-require-attestation") { if flagValue, err := cmd.Flags().GetBool("slsa-require-attestation"); err == nil { requireAttestation = flagValue @@ -529,6 +536,7 @@ func parseSLSAConfig(cmd *cobra.Command) (*cache.SLSAConfig, error) { return &cache.SLSAConfig{ Verification: true, SourceURI: slsaSourceURI, + SourceRef: slsaSourceRef, TrustedRoots: []string{"https://fulcio.sigstore.dev"}, RequireAttestation: requireAttestation, }, nil diff --git a/cmd/build_test.go b/cmd/build_test.go index 9ea903b3..aecdd1e6 100644 --- a/cmd/build_test.go +++ b/cmd/build_test.go @@ -264,11 +264,14 @@ func TestParseSLSAConfig(t *testing.T) { name string envVerification string envSourceURI string + envSourceRef string envRequireAttestation string flagVerification *bool flagSourceURI *string + flagSourceRef *string flagRequireAttestation *bool wantConfig bool + wantSourceRef string wantRequireAttestation bool wantError bool }{ @@ -287,6 +290,23 @@ func TestParseSLSAConfig(t *testing.T) { envSourceURI: "https://github.com/gitpod-io/leeway", wantConfig: true, }, + { + name: "source ref via env", + envVerification: "true", + envSourceURI: "https://github.com/gitpod-io/leeway", + envSourceRef: "refs/heads/main", + wantConfig: true, + wantSourceRef: "refs/heads/main", + }, + { + name: "source ref flag overrides env", + envVerification: "true", + envSourceURI: "https://github.com/gitpod-io/leeway", + envSourceRef: "refs/heads/other", + flagSourceRef: stringPtr("refs/heads/main"), + wantConfig: true, + wantSourceRef: "refs/heads/main", + }, { name: "require attestation via env", envVerification: "true", @@ -318,12 +338,16 @@ func TestParseSLSAConfig(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Set environment variables + t.Setenv(EnvvarSLSASourceRef, "") if tt.envVerification != "" { t.Setenv(EnvvarSLSACacheVerification, tt.envVerification) } if tt.envSourceURI != "" { t.Setenv(EnvvarSLSASourceURI, tt.envSourceURI) } + if tt.envSourceRef != "" { + t.Setenv(EnvvarSLSASourceRef, tt.envSourceRef) + } if tt.envRequireAttestation != "" { t.Setenv(EnvvarSLSARequireAttestation, tt.envRequireAttestation) } @@ -346,6 +370,11 @@ func TestParseSLSAConfig(t *testing.T) { t.Fatalf("failed to set source URI flag: %v", err) } } + if tt.flagSourceRef != nil { + if err := cmd.Flags().Set("slsa-source-ref", *tt.flagSourceRef); err != nil { + t.Fatalf("failed to set source ref flag: %v", err) + } + } if tt.flagRequireAttestation != nil { if err := cmd.Flags().Set("slsa-require-attestation", boolToString(*tt.flagRequireAttestation)); err != nil { t.Fatalf("failed to set require attestation flag: %v", err) @@ -373,6 +402,9 @@ func TestParseSLSAConfig(t *testing.T) { if config.RequireAttestation != tt.wantRequireAttestation { t.Errorf("expected RequireAttestation=%v, got %v", tt.wantRequireAttestation, config.RequireAttestation) } + if config.SourceRef != tt.wantSourceRef { + t.Errorf("expected SourceRef=%q, got %q", tt.wantSourceRef, config.SourceRef) + } } else { if config != nil { t.Errorf("expected nil config but got %+v", config) @@ -386,6 +418,10 @@ func boolPtr(b bool) *bool { return &b } +func stringPtr(s string) *string { + return &s +} + func boolToString(b bool) string { if b { return "true" diff --git a/cmd/root.go b/cmd/root.go index 3327eb8b..e7c0c181 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,6 +34,9 @@ const ( // EnvvarSLSASourceURI configures the expected source URI for SLSA verification EnvvarSLSASourceURI = "LEEWAY_SLSA_SOURCE_URI" + // EnvvarSLSASourceRef restricts SLSA verification to an exact source ref + EnvvarSLSASourceRef = "LEEWAY_SLSA_SOURCE_REF" + // EnvvarSLSARequireAttestation requires SLSA attestations (missing/invalid → build locally) EnvvarSLSARequireAttestation = "LEEWAY_SLSA_REQUIRE_ATTESTATION" diff --git a/pkg/leeway/cache/remote/s3.go b/pkg/leeway/cache/remote/s3.go index a2ce4b1e..963d244b 100644 --- a/pkg/leeway/cache/remote/s3.go +++ b/pkg/leeway/cache/remote/s3.go @@ -120,9 +120,10 @@ func NewS3Cache(cfg *cache.RemoteConfig) (*S3Cache, error) { // Initialize SLSA verifier if enabled var slsaVerifier slsa.VerifierInterface if cfg.SLSA != nil && cfg.SLSA.Verification && cfg.SLSA.SourceURI != "" { - slsaVerifier = slsa.NewVerifier(cfg.SLSA.SourceURI, cfg.SLSA.TrustedRoots) + slsaVerifier = slsa.NewVerifierForRef(cfg.SLSA.SourceURI, cfg.SLSA.SourceRef, cfg.SLSA.TrustedRoots) log.WithFields(log.Fields{ "sourceURI": cfg.SLSA.SourceURI, + "sourceRef": cfg.SLSA.SourceRef, "trustedRoots": len(cfg.SLSA.TrustedRoots), }).Debug("SLSA verification enabled for cache") } diff --git a/pkg/leeway/cache/slsa/verifier.go b/pkg/leeway/cache/slsa/verifier.go index c72ea3c2..34b8fe76 100644 --- a/pkg/leeway/cache/slsa/verifier.go +++ b/pkg/leeway/cache/slsa/verifier.go @@ -8,15 +8,23 @@ import ( "encoding/json" "fmt" "io" + "net/url" "os" + "regexp" + "strings" "time" "github.com/sigstore/sigstore-go/pkg/bundle" + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" "github.com/sigstore/sigstore-go/pkg/root" "github.com/sigstore/sigstore-go/pkg/verify" log "github.com/sirupsen/logrus" ) +const githubActionsOIDCIssuer = "https://token.actions.githubusercontent.com" + +var validGitHubRepositoryComponent = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) + // VerificationFailedError is returned when SLSA verification fails type VerificationFailedError struct { Reason string @@ -34,13 +42,20 @@ type VerifierInterface interface { // Verifier handles SLSA attestation verification using Go API type Verifier struct { sourceURI string + sourceRef string trustedRoots []string } // NewVerifier creates a new SLSA verifier instance func NewVerifier(sourceURI string, trustedRoots []string) *Verifier { + return NewVerifierForRef(sourceURI, "", trustedRoots) +} + +// NewVerifierForRef creates a verifier restricted to an optional source ref. +func NewVerifierForRef(sourceURI, sourceRef string, trustedRoots []string) *Verifier { return &Verifier{ sourceURI: sourceURI, + sourceRef: sourceRef, trustedRoots: trustedRoots, } } @@ -102,13 +117,18 @@ func (v *Verifier) VerifyArtifact(ctx context.Context, artifactPath, attestation } }() - // Step 5: Create verification policy - // WithArtifact provides the artifact for hash verification - // WithoutIdentitiesUnsafe skips identity verification (we only care about signature) - // In production, you might want to verify the identity (GitHub Actions workflow) + // Step 5: Create verification policy. The certificate identity binds the + // signature to the configured GitHub repository and optional source ref. + identity, err := newGitHubCertificateIdentity(v.sourceURI, v.sourceRef) + if err != nil { + return VerificationFailedError{ + Reason: fmt.Sprintf("invalid certificate identity policy: %v", err), + } + } + policy := verify.NewPolicy( verify.WithArtifact(artifactFile), - verify.WithoutIdentitiesUnsafe(), + verify.WithCertificateIdentity(identity), ) // Step 6: Verify the bundle @@ -118,6 +138,7 @@ func (v *Verifier) VerifyArtifact(ctx context.Context, artifactPath, attestation // - Transparency log entry is valid (using embedded tlog_entries!) // - Timestamps are consistent // - Artifact hash matches (if provided) + // - Certificate identity matches the configured repository and source ref _, err = verifier.Verify(b, policy) if err != nil { return VerificationFailedError{ @@ -209,6 +230,72 @@ func (v *Verifier) VerifyArtifact(ctx context.Context, artifactPath, attestation return nil } +func newGitHubCertificateIdentity(sourceURI, sourceRef string) (verify.CertificateIdentity, error) { + repositoryURI, err := normalizeGitHubRepositoryURI(sourceURI) + if err != nil { + return verify.CertificateIdentity{}, err + } + + if sourceRef != "" && !strings.HasPrefix(sourceRef, "refs/") { + return verify.CertificateIdentity{}, fmt.Errorf("source ref must start with refs/") + } + + refPattern := `[^@]+` + if sourceRef != "" { + refPattern = regexp.QuoteMeta(sourceRef) + } + sanPattern := fmt.Sprintf(`^%s/\.github/workflows/[^/@]+@%s$`, regexp.QuoteMeta(repositoryURI), refPattern) + + sanMatcher, err := verify.NewSANMatcher("", sanPattern) + if err != nil { + return verify.CertificateIdentity{}, fmt.Errorf("cannot create certificate SAN matcher: %w", err) + } + issuerMatcher, err := verify.NewIssuerMatcher(githubActionsOIDCIssuer, "") + if err != nil { + return verify.CertificateIdentity{}, fmt.Errorf("cannot create certificate issuer matcher: %w", err) + } + + return verify.NewCertificateIdentity(sanMatcher, issuerMatcher, certificate.Extensions{ + SourceRepositoryURI: repositoryURI, + SourceRepositoryRef: sourceRef, + }) +} + +func normalizeGitHubRepositoryURI(sourceURI string) (string, error) { + raw := strings.TrimSpace(sourceURI) + if raw == "" { + return "", fmt.Errorf("source URI is empty") + } + + if strings.HasPrefix(raw, "git@github.com:") { + raw = "https://github.com/" + strings.TrimPrefix(raw, "git@github.com:") + } else if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + + parsed, err := url.Parse(raw) + if err != nil { + return "", fmt.Errorf("cannot parse source URI: %w", err) + } + if !strings.EqualFold(parsed.Hostname(), "github.com") { + return "", fmt.Errorf("source URI must identify a github.com repository") + } + if parsed.RawQuery != "" || parsed.Fragment != "" { + return "", fmt.Errorf("source URI must not contain a query or fragment") + } + + repositoryPath := strings.TrimSuffix(strings.Trim(parsed.Path, "/"), ".git") + parts := strings.Split(repositoryPath, "/") + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", fmt.Errorf("source URI must contain an owner and repository") + } + if !validGitHubRepositoryComponent.MatchString(parts[0]) || !validGitHubRepositoryComponent.MatchString(parts[1]) { + return "", fmt.Errorf("source URI contains an invalid owner or repository") + } + + return "https://github.com/" + strings.Join(parts, "/"), nil +} + // calculateSHA256 calculates the SHA256 hash of a file func (v *Verifier) calculateSHA256(filePath string) (string, error) { file, err := os.Open(filePath) diff --git a/pkg/leeway/cache/slsa/verifier_test.go b/pkg/leeway/cache/slsa/verifier_test.go index 5dc98397..ecdd4396 100644 --- a/pkg/leeway/cache/slsa/verifier_test.go +++ b/pkg/leeway/cache/slsa/verifier_test.go @@ -6,6 +6,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/sigstore/sigstore-go/pkg/fulcio/certificate" ) func TestNewVerifier(t *testing.T) { @@ -27,6 +29,122 @@ func TestNewVerifier(t *testing.T) { } } +func TestNewVerifierForRef(t *testing.T) { + verifier := NewVerifierForRef("github.com/gitpod-io/gitpod-next", "refs/heads/main", nil) + + if verifier.sourceRef != "refs/heads/main" { + t.Errorf("expected source ref refs/heads/main, got %q", verifier.sourceRef) + } +} + +func TestNormalizeGitHubRepositoryURI(t *testing.T) { + tests := []struct { + name string + sourceURI string + want string + wantError bool + }{ + {name: "HTTPS", sourceURI: "https://github.com/gitpod-io/gitpod-next", want: "https://github.com/gitpod-io/gitpod-next"}, + {name: "HTTPS Git suffix", sourceURI: "https://github.com/gitpod-io/gitpod-next.git", want: "https://github.com/gitpod-io/gitpod-next"}, + {name: "HTTPS Git suffix and slash", sourceURI: "https://github.com/gitpod-io/gitpod-next.git/", want: "https://github.com/gitpod-io/gitpod-next"}, + {name: "host and path", sourceURI: "github.com/gitpod-io/gitpod-next", want: "https://github.com/gitpod-io/gitpod-next"}, + {name: "SSH", sourceURI: "git@github.com:gitpod-io/gitpod-next.git", want: "https://github.com/gitpod-io/gitpod-next"}, + {name: "empty", wantError: true}, + {name: "wrong host", sourceURI: "https://example.com/gitpod-io/gitpod-next", wantError: true}, + {name: "missing repository", sourceURI: "https://github.com/gitpod-io", wantError: true}, + {name: "extra path", sourceURI: "https://github.com/gitpod-io/gitpod-next/actions", wantError: true}, + {name: "query", sourceURI: "https://github.com/gitpod-io/gitpod-next?ref=main", wantError: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizeGitHubRepositoryURI(tt.sourceURI) + if tt.wantError { + if err == nil { + t.Fatalf("expected an error, got %q", got) + } + return + } + if err != nil { + t.Fatalf("normalizeGitHubRepositoryURI failed: %v", err) + } + if got != tt.want { + t.Errorf("expected %q, got %q", tt.want, got) + } + }) + } +} + +func TestGitHubCertificateIdentity(t *testing.T) { + identity, err := newGitHubCertificateIdentity("git@github.com:gitpod-io/gitpod-next.git", "refs/heads/main") + if err != nil { + t.Fatalf("newGitHubCertificateIdentity failed: %v", err) + } + + valid := certificate.Summary{ + SubjectAlternativeName: "https://github.com/gitpod-io/gitpod-next/.github/workflows/build-main.yml@refs/heads/main", + Extensions: certificate.Extensions{ + Issuer: githubActionsOIDCIssuer, + SourceRepositoryURI: "https://github.com/gitpod-io/gitpod-next", + SourceRepositoryRef: "refs/heads/main", + }, + } + + tests := []struct { + name string + mutate func(*certificate.Summary) + wantErr bool + }{ + {name: "trusted main signer"}, + { + name: "wrong issuer", + mutate: func(summary *certificate.Summary) { + summary.Issuer = "https://issuer.example.com" + }, + wantErr: true, + }, + { + name: "wrong repository", + mutate: func(summary *certificate.Summary) { + summary.SubjectAlternativeName = "https://github.com/attacker/repo/.github/workflows/build.yml@refs/heads/main" + summary.SourceRepositoryURI = "https://github.com/attacker/repo" + }, + wantErr: true, + }, + { + name: "wrong ref", + mutate: func(summary *certificate.Summary) { + summary.SubjectAlternativeName = "https://github.com/gitpod-io/gitpod-next/.github/workflows/build-branch.yml@refs/pull/123/merge" + summary.SourceRepositoryRef = "refs/pull/123/merge" + }, + wantErr: true, + }, + { + name: "missing repository claim", + mutate: func(summary *certificate.Summary) { + summary.SourceRepositoryURI = "" + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + summary := valid + if tt.mutate != nil { + tt.mutate(&summary) + } + err := identity.Verify(summary) + if tt.wantErr && err == nil { + t.Fatal("expected identity verification to fail") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected identity verification to succeed: %v", err) + } + }) + } +} + func TestAttestationKey(t *testing.T) { tests := []struct { name string diff --git a/pkg/leeway/cache/types.go b/pkg/leeway/cache/types.go index 9d1a4790..c7270595 100644 --- a/pkg/leeway/cache/types.go +++ b/pkg/leeway/cache/types.go @@ -172,6 +172,9 @@ type SLSAConfig struct { // SourceURI is the expected source URI for SLSA verification SourceURI string `yaml:"source_uri" json:"source_uri"` + // SourceRef optionally restricts attestations to an exact source ref. + SourceRef string `yaml:"source_ref" json:"source_ref"` + // TrustedRoots contains the trusted root certificates for SLSA verification TrustedRoots []string `yaml:"trusted_roots" json:"trusted_roots"`