Skip to content
Draft
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
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
36 changes: 36 additions & 0 deletions cmd/build_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}{
Expand All @@ -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",
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 2 additions & 1 deletion pkg/leeway/cache/remote/s3.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
97 changes: 92 additions & 5 deletions pkg/leeway/cache/slsa/verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading