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
7 changes: 7 additions & 0 deletions internal/cmd/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,9 @@ sourcetool is about to perform the following actions on your behalf:
cmd.Context(), opts.GetBranch().Repository, []*models.Branch{opts.GetBranch()},
)
if err != nil {
if errors.Is(err, models.ErrUnsupportedRepoPlan) {
return unsupportedRepoPlanError(opts.GetRepository().Path)
}
return fmt.Errorf("onboarding repo: %w", err)
}

Expand Down Expand Up @@ -446,6 +449,10 @@ a fork of the repository you want to protect.
cmd.Context(), opts.GetBranch().Repository, []*models.Branch{opts.GetBranch()}, cs,
)
if err != nil {
if errors.Is(err, models.ErrUnsupportedRepoPlan) {
return unsupportedRepoPlanError(opts.GetRepository().Path)
}

// if strings.Contains(err.Error(), models.ErrProtectionAlreadyInPlace.Error()) {
if errors.Is(err, models.ErrProtectionAlreadyInPlace) {
fmt.Printf("\n ℹ️ Controls already enabled on %s\n\n", opts.GetRepository().Path)
Expand Down
19 changes: 19 additions & 0 deletions internal/cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,24 @@ import (
"github.com/slsa-framework/source-tool/pkg/policy"
"github.com/slsa-framework/source-tool/pkg/slsa"
"github.com/slsa-framework/source-tool/pkg/sourcetool"
"github.com/slsa-framework/source-tool/pkg/sourcetool/models"
)

// unsupportedRepoPlanError builds a user facing error explaining that the
// repository plan does not support reading branch rules. This is the friendly
// message the setup and status subcommands print instead of the raw GitHub 403
// "Upgrade to GitHub Pro or make this repository public" response.
func unsupportedRepoPlanError(repoPath string) error {
return fmt.Errorf(
"%s is a private repository on a GitHub plan that does not expose branch "+
"rules to the API.\n"+
"sourcetool cannot read or configure SLSA source controls on it yet.\n\n"+
"To continue, either make the repository public or upgrade its account "+
"to a plan that includes repository rules (GitHub Pro or higher)",
repoPath,
)
}

var (
w = color.New(color.FgHiWhite, color.BgBlack).SprintFunc()
w2 = color.New(color.Faint, color.FgWhite, color.BgBlack).SprintFunc()
Expand Down Expand Up @@ -112,6 +128,9 @@ sourcetool status myorg/myrepo@mybranch
// Get the active repository controls
controls, err := srctool.GetBranchControls(cmd.Context(), opts.GetBranch())
if err != nil {
if errors.Is(err, models.ErrUnsupportedRepoPlan) {
return unsupportedRepoPlanError(opts.GetRepository().Path)
}
return fmt.Errorf("fetching active controls: %w", err)
}

Expand Down
31 changes: 31 additions & 0 deletions pkg/sourcetool/backends/vcs/github/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,40 @@
"errors"
"fmt"
"log"
"net/http"
"time"

"github.com/google/go-github/v69/github"

Check failure on line 14 in pkg/sourcetool/backends/vcs/github/github.go

View workflow job for this annotation

GitHub Actions / unit-test (1.25.11)

no required module provides package github.com/google/go-github/v69/github; to add it:

Check failure on line 14 in pkg/sourcetool/backends/vcs/github/github.go

View workflow job for this annotation

GitHub Actions / unit-test (1.25.11)

no required module provides package github.com/google/go-github/v69/github; to add it:

Check failure on line 14 in pkg/sourcetool/backends/vcs/github/github.go

View workflow job for this annotation

GitHub Actions / unit-test (1.25.11)

no required module provides package github.com/google/go-github/v69/github; to add it:

Check failure on line 14 in pkg/sourcetool/backends/vcs/github/github.go

View workflow job for this annotation

GitHub Actions / unit-test (1.25.11)

no required module provides package github.com/google/go-github/v69/github; to add it:

Check failure on line 14 in pkg/sourcetool/backends/vcs/github/github.go

View workflow job for this annotation

GitHub Actions / unit-test (1.26.4)

no required module provides package github.com/google/go-github/v69/github; to add it:

Check failure on line 14 in pkg/sourcetool/backends/vcs/github/github.go

View workflow job for this annotation

GitHub Actions / unit-test (1.26.4)

no required module provides package github.com/google/go-github/v69/github; to add it:

Check failure on line 14 in pkg/sourcetool/backends/vcs/github/github.go

View workflow job for this annotation

GitHub Actions / unit-test (1.26.4)

no required module provides package github.com/google/go-github/v69/github; to add it:

Check failure on line 14 in pkg/sourcetool/backends/vcs/github/github.go

View workflow job for this annotation

GitHub Actions / unit-test (1.26.4)

no required module provides package github.com/google/go-github/v69/github; to add it:

"github.com/slsa-framework/source-tool/pkg/attest"
"github.com/slsa-framework/source-tool/pkg/auth"
"github.com/slsa-framework/source-tool/pkg/ghcontrol"
"github.com/slsa-framework/source-tool/pkg/slsa"
"github.com/slsa-framework/source-tool/pkg/sourcetool/models"
)

// asUnsupportedPlanError inspects an error returned from the GitHub API and,
// when it is a 403 raised because the repository's plan does not include the
// requested feature (for example reading branch rulesets on a private repo on a
// free plan), returns models.ErrUnsupportedRepoPlan wrapping the original
// error. It returns nil if err is not that case so callers can fall through to
// their normal handling. The check is done on the typed *github.ErrorResponse
// rather than on the error string so it does not break if GitHub rewords the
// message.
func asUnsupportedPlanError(err error) error {
if err == nil {
return nil
}
var ghErr *github.ErrorResponse
if !errors.As(err, &ghErr) {
return nil
}
if ghErr.Response == nil || ghErr.Response.StatusCode != http.StatusForbidden {
return nil
}
return fmt.Errorf("%w: %w", models.ErrUnsupportedRepoPlan, err)
}

// InherentControls are the controls that are always true because we are
// in git and/org GitHub.
var InherentControls = slsa.ControlNameSet{
Expand Down Expand Up @@ -146,6 +171,12 @@
// legacy checks sourcetool did (continuity, review, RequiredChecks, tag hygiene)
activeControls, err := ghc.GetBranchControls(ctx, branch.FullRef())
if err != nil {
// Reading branch rules 403s on private repos that are on a free plan.
// Surface a typed, actionable error before anything else happens so the
// caller can warn the user instead of leaking the raw API message.
if planErr := asUnsupportedPlanError(err); planErr != nil {
return nil, planErr
}
return nil, fmt.Errorf("checking status: %w", err)
}

Expand Down
86 changes: 86 additions & 0 deletions pkg/sourcetool/backends/vcs/github/github_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// SPDX-FileCopyrightText: Copyright 2025 The SLSA Authors
// SPDX-License-Identifier: Apache-2.0

package github

import (
"errors"
"fmt"
"net/http"
"testing"

"github.com/google/go-github/v69/github"
"github.com/stretchr/testify/require"

"github.com/slsa-framework/source-tool/pkg/sourcetool/models"
)

func TestAsUnsupportedPlanError(t *testing.T) {
t.Parallel()

// This is the shape GitHub returns when reading branch rules on a private
// repo that is on a free plan (see slsa-framework/source-tool#326). The
// detection must key off the typed response and status code, not the
// message text, so the test uses the real go-github error type.
forbidden := &github.ErrorResponse{
Response: &http.Response{StatusCode: http.StatusForbidden},
Message: "Upgrade to GitHub Pro or make this repository public to enable this feature.",
}

for _, tc := range []struct {
name string
err error
expectPlan bool
}{
{
name: "nil",
err: nil,
expectPlan: false,
},
{
name: "plain-403",
err: forbidden,
expectPlan: true,
},
{
name: "wrapped-403",
err: fmt.Errorf("checking status: %w", forbidden),
expectPlan: true,
},
{
name: "404-not-plan",
err: &github.ErrorResponse{
Response: &http.Response{StatusCode: http.StatusNotFound},
Message: "Not Found",
},
expectPlan: false,
},
{
name: "non-github-error",
err: errors.New("some other failure"),
expectPlan: false,
},
{
name: "403-without-response",
err: &github.ErrorResponse{
Message: "Forbidden but no response attached",
},
expectPlan: false,
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got := asUnsupportedPlanError(tc.err)
if !tc.expectPlan {
require.NoError(t, got)
return
}
require.Error(t, got)
// The actionable sentinel must be detectable with errors.Is so the
// CLI can switch on it...
require.ErrorIs(t, got, models.ErrUnsupportedRepoPlan)
// ...and the original API error must still be reachable for debugging.
require.ErrorIs(t, got, tc.err)
})
}
}
6 changes: 6 additions & 0 deletions pkg/sourcetool/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import (
var (
ErrProtectionAlreadyInPlace = errors.New("controls already in place in the repository")
ErrRepositoryAccessDenied = errors.New("access to repository denied")
// ErrUnsupportedRepoPlan is returned when a control check or configuration
// hits a GitHub feature that the repository's plan does not include. This
// happens, for example, when reading branch rulesets on a private repo that
// is on a free plan: GitHub answers with a 403 asking to upgrade or make the
// repo public.
ErrUnsupportedRepoPlan = errors.New("repository plan does not support this feature (private repositories require GitHub Pro or a public repository to read branch rules)")
)

// AttestationStorageReader abstracts an attestation storage system where
Expand Down
10 changes: 10 additions & 0 deletions pkg/sourcetool/tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ func (t *Tool) OnboardRepository(ctx context.Context, repo *models.Repository, b
return fmt.Errorf("verifying options: %w", err)
}

// Read the current controls before changing anything. This is a read-only
// call that fails fast with models.ErrUnsupportedRepoPlan when the repo is
// private on a free plan, letting us warn the user before we mutate the
// repository.
for _, branch := range branches {
if _, err := t.impl.GetBranchControls(ctx, t.backend, branch); err != nil {
return fmt.Errorf("checking repository controls: %w", err)
}
}

if err := t.backend.ConfigureControls(
repo, branches, []models.ControlConfiguration{
models.CONFIG_BRANCH_RULES, models.CONFIG_GEN_PROVENANCE, models.CONFIG_TAG_RULES,
Expand Down
29 changes: 29 additions & 0 deletions pkg/sourcetool/tool_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,3 +344,32 @@ func TestOnboardRepository(t *testing.T) {
})
}
}

// TestOnboardRepositoryUnsupportedPlanWarnsBeforeMutation reproduces
// slsa-framework/source-tool#326: when the repository plan does not support
// reading branch rules (a private repo on a free plan returns a 403), the
// onboard flow must surface models.ErrUnsupportedRepoPlan and must NOT perform
// any mutating action (it must not call the backend ConfigureControls).
func TestOnboardRepositoryUnsupportedPlanWarnsBeforeMutation(t *testing.T) {
t.Parallel()

timp := &sourcetoolfakes.FakeToolImplementation{}
timp.VerifyOptionsForFullOnboardReturns(nil)
// The pre-mutation read trips the plan limitation.
timp.GetBranchControlsReturns(nil, models.ErrUnsupportedRepoPlan)

bend := &modelsfakes.FakeVcsBackend{}

tool := &Tool{impl: timp, backend: bend}

err := tool.OnboardRepository(
t.Context(), &models.Repository{Path: "example/repo"}, []*models.Branch{{Name: "main"}},
)

require.Error(t, err)
require.ErrorIs(t, err, models.ErrUnsupportedRepoPlan)

// The mutation must never run: ConfigureControls is the destructive call and
// it has to stay at zero invocations.
require.Equal(t, 0, bend.ConfigureControlsCallCount(), "must not mutate the repository after an unsupported-plan warning")
}
Loading