diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index 4e269643..65277281 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -151,6 +151,45 @@ Alternatively, to enable each control individually use: sourcetool setup control return err } + // Check the control prerequisites + preReqOut := false + for _, cc := range []models.ControlConfiguration{ + models.CONFIG_TAG_RULES, models.CONFIG_GEN_PROVENANCE, models.CONFIG_BRANCH_RULES, + } { + ok, actionDescr, remediateFn, err := srctool.ControlPrecheck( + opts.GetBranch().Repository, []*models.Branch{opts.GetBranch()}, cc, + ) + if err != nil { + return fmt.Errorf("checking prerequisites for %s: %w", cc, err) + } + + if !ok { + if !preReqOut { + fmt.Println() + fmt.Println("🟠 " + w("Prerequisites Check:")) + preReqOut = true + } + fmt.Println(">> " + actionDescr) + fmt.Println() + + _, s, err := util.Ask("Type 'yes' if you want to continue", "yes|no|no", 3) + if err != nil { + return err + } + + if !s { + return fmt.Errorf("prerequisites for %s not met", cc) + } + + msg, err := remediateFn() + if err != nil { + return err + } + + fmt.Printf("☑️ %s\n", msg) + } + } + if opts.interactive { fmt.Printf(` sourcetool is about to perform the following actions on your behalf: @@ -165,7 +204,7 @@ sourcetool is about to perform the following actions on your behalf: srctool.ControlConfigurationDescr(opts.GetBranch(), models.CONFIG_BRANCH_RULES), ) - _, s, err := util.Ask("Type 'yes' if you want to continue?", "yes|no|no", 3) + _, s, err := util.Ask("Type 'yes' if you want to continue", "yes|no|no", 3) if err != nil { return err } @@ -323,18 +362,57 @@ a fork of the repository you want to protect. return err } } + questions := "" + preReqOut := false + for _, c := range opts.configs { + // Run the control preflight check + cc := models.ControlConfiguration(c) + + // Check the control prerequisites + ok, actionDescr, remediateFn, err := srctool.ControlPrecheck( + opts.GetBranch().Repository, []*models.Branch{opts.GetBranch()}, cc, + ) + if err != nil { + return fmt.Errorf("checking prerequisites for %s: %w", cc, err) + } + + if !ok { + if !preReqOut { + fmt.Println() + fmt.Println("🟠 " + w("Prerequisites Check:")) + preReqOut = true + } + fmt.Println(">> " + actionDescr) + fmt.Println() + + _, s, err := util.Ask("Type 'yes' if you want to continue", "yes|no|no", 3) + if err != nil { + return err + } + + if !s { + return fmt.Errorf("prerequisites for %s not met", cc) + } + + msg, err := remediateFn() + if err != nil { + return err + } + + fmt.Printf("☑️ %s\n", msg) + } + + cs = append(cs, cc) + questions += fmt.Sprintf(" - %s.\n", srctool.ControlConfigurationDescr(opts.GetBranch(), models.ControlConfiguration(c))) + } fmt.Println() fmt.Println("sourcetool is about to perform the following actions on your behalf:") fmt.Println() + fmt.Print(questions) + fmt.Println() - for _, c := range opts.configs { - cs = append(cs, models.ControlConfiguration(c)) - fmt.Printf(" - %s.\n", srctool.ControlConfigurationDescr(opts.GetBranch(), models.ControlConfiguration(c))) - } - fmt.Println("") - - _, s, err := util.Ask("Type 'yes' if you want to continue?", "yes|no|no", 3) + _, s, err := util.Ask("Type 'yes' if you want to continue", "yes|no|no", 3) if err != nil { return err } @@ -345,7 +423,20 @@ a fork of the repository you want to protect. } } else { for _, c := range opts.configs { - cs = append(cs, models.ControlConfiguration(c)) + cc := models.ControlConfiguration(c) + // Run the prerequisites and run any remediations + ok, _, remediateFn, err := srctool.ControlPrecheck(opts.GetBranch().Repository, []*models.Branch{opts.GetBranch()}, cc) + if err != nil { + return fmt.Errorf("checking prerequisites for %q: %w", cc, err) + } + if !ok { + msg, err := remediateFn() + if err != nil { + return fmt.Errorf("running remedaition for %q prereqs: %w", cc, err) + } + fmt.Println(msg) + } + cs = append(cs, cc) } } err = srctool.ConfigureControls( diff --git a/pkg/sourcetool/backends/vcs/github/github.go b/pkg/sourcetool/backends/vcs/github/github.go index 8814fa73..10c9edbd 100644 --- a/pkg/sourcetool/backends/vcs/github/github.go +++ b/pkg/sourcetool/backends/vcs/github/github.go @@ -20,12 +20,18 @@ import ( func New() *Backend { return &Backend{ authenticator: auth.New(), + Options: Options{UseFork: true}, } } +type Options struct { + UseFork bool +} + // Backend implemets the GitHub sourcetool backend type Backend struct { authenticator *auth.Authenticator + Options Options } // getGitHubConnection builds a github connector to a repository diff --git a/pkg/sourcetool/backends/vcs/github/manage.go b/pkg/sourcetool/backends/vcs/github/manage.go index 1cd67251..b9b62487 100644 --- a/pkg/sourcetool/backends/vcs/github/manage.go +++ b/pkg/sourcetool/backends/vcs/github/manage.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "net/http" "strings" "github.com/google/go-github/v69/github" @@ -49,6 +50,30 @@ jobs: ` ) +// checkPushAccess +func (b *Backend) checkPushAccess(r *models.Repository) (bool, error) { + client, err := b.authenticator.GetGitHubClient() + if err != nil { + return false, err + } + owner, repoName, err := r.PathAsGitHubOwnerName() + if err != nil { + return false, err + } + + //nolint:noctx + resp, err := client.Client().Get(fmt.Sprintf("https://api.github.com/repos/%s/%s/collaborators", owner, repoName)) + if resp.StatusCode == http.StatusForbidden { + return false, nil + } + if err != nil { + resp.Body.Close() //nolint:errcheck,gosec + return false, fmt.Errorf("checking repository access: %w", err) + } + resp.Body.Close() //nolint:errcheck,gosec + return true, nil +} + // CreateWorkflowPR creates the pull request to add the provenance workflow // to the specified repository. func (b *Backend) CreateWorkflowPR(r *models.Repository, branches []*models.Branch) (*models.PullRequest, error) { @@ -63,10 +88,20 @@ func (b *Backend) CreateWorkflowPR(r *models.Repository, branches []*models.Bran } workflowYAML := fmt.Sprintf(workflowData, strings.Join(quotedBranchesList, ", ")) + // We need to determine if the user needs a fork + hasPush, err := b.checkPushAccess(r) + if err != nil { + return nil, fmt.Errorf("checking for repository push access: %w", err) + } + + // If user does not have push access, use a fork + if err := b.CheckWorkflowFork(r); err != nil { + return nil, fmt.Errorf("checking for required repository fork: %w", err) + } + // Create a PR manager prManager := repo.NewPullRequestManager(repo.WithAuthenticator(b.authenticator)) - - // TODO(puerco): Honor forks settings, etc + prManager.Options.UseFork = !hasPush // Open the pull request pr, err := prManager.PullRequestFileList( @@ -93,7 +128,7 @@ func (b *Backend) CreateWorkflowPR(r *models.Repository, branches []*models.Bran // CheckWorkflowFork verifies that the user has a fork of the repository // we are configuring. func (b *Backend) CheckWorkflowFork(r *models.Repository) error { - // Create a PAR manager + // Create a PR manager prManager := repo.NewPullRequestManager(repo.WithAuthenticator(b.authenticator)) // TODO(puerco): Support forkname from options @@ -195,6 +230,75 @@ func (b *Backend) CreateTagRuleset(r *models.Repository) error { return nil } +// CreateRepositoryFork creates a fork of a repo into the logged-in user's org. +// Optionally the fork can have a different name than the original. +func (b *Backend) createRepositoryFork( + src *models.Repository, forkName string, +) error { + client, err := b.authenticator.GetGitHubClient() + if err != nil { + return fmt.Errorf("creating GitHub client: %w", err) + } + + srcOrg, srcName, err := src.PathAsGitHubOwnerName() + if err != nil { + return err + } + + if forkName == "" { + forkName = srcName + } + + // Create the fork + _, resp, err := client.Repositories.CreateFork( + context.Background(), srcOrg, srcName, &github.RepositoryCreateForkOptions{ + Name: forkName, + }, + ) + + // GitHub will return 202 for larger repos that are cloned async + if err != nil && resp.StatusCode != http.StatusAccepted { + return fmt.Errorf("creating repository fork: %w", err) + } + + return nil +} + +// ControlPrecheck checks if the prerequisites to enable the controls are OK +func (b *Backend) ControlPrecheck( + r *models.Repository, branches []*models.Branch, config models.ControlConfiguration, +) (ok bool, remediationMessage string, remediateFn models.ControlPreRemediationFn, err error) { + //nolint:exhaustive // Not all configs have prechecks + switch config { + case models.CONFIG_GEN_PROVENANCE: + sino, err := b.checkPushAccess(r) + if err != nil { + return false, "", nil, fmt.Errorf("checking for push access: %w", err) + } + // If user has push access, everything is OK + if sino { + return true, "", nil, nil + } + + // No push access, check if user has a fork + if err := b.CheckWorkflowFork(r); err == nil { + // Fork found, all ok + return true, "", nil, nil + } + msg := "No fork found of repository %s\n" + msg += "and user has no push access.\n\n" + msg += "Would you like to create a fork in your account?\n" + return false, fmt.Sprintf(msg, r.Path), func() (string, error) { + if err := b.createRepositoryFork(r, ""); err != nil { + return "", fmt.Errorf("creating repository fork: %w", err) + } + return "successfully created the repository fork", nil + }, nil + default: + return true, "", nil, nil + } +} + // ConfigureControls configure the SLSA controls in the repository func (b *Backend) ConfigureControls(r *models.Repository, branches []*models.Branch, configs []models.ControlConfiguration) error { errs := []error{} diff --git a/pkg/sourcetool/implementation.go b/pkg/sourcetool/implementation.go index 588d3685..51d543b0 100644 --- a/pkg/sourcetool/implementation.go +++ b/pkg/sourcetool/implementation.go @@ -100,7 +100,7 @@ func (impl *defaultToolImplementation) CreatePolicyPR(a *auth.Authenticator, opt return nil, fmt.Errorf("checking policy repository fork: %w", err) } - // MArshal the policy json + // Marshal the policy json policyJson, err := json.MarshalIndent(p, "", " ") if err != nil { return nil, fmt.Errorf("marshaling policy data: %w", err) diff --git a/pkg/sourcetool/models/models.go b/pkg/sourcetool/models/models.go index 82868ac1..bd9e0474 100644 --- a/pkg/sourcetool/models/models.go +++ b/pkg/sourcetool/models/models.go @@ -42,8 +42,13 @@ type VcsBackend interface { ControlConfigurationDescr(*Branch, ControlConfiguration) string ConfigureControls(*Repository, []*Branch, []ControlConfiguration) error GetLatestCommit(context.Context, *Repository, *Branch) (*Commit, error) + ControlPrecheck(*Repository, []*Branch, ControlConfiguration) (bool, string, ControlPreRemediationFn, error) } +// ControlPreRemediation is a function returned by the VCS backends +// when checking for prerequisites that the user may optionally run +type ControlPreRemediationFn func() (string, error) + type ControlConfiguration string const ( diff --git a/pkg/sourcetool/models/modelsfakes/fake_vcs_backend.go b/pkg/sourcetool/models/modelsfakes/fake_vcs_backend.go index 3a86857c..3307b6a2 100644 --- a/pkg/sourcetool/models/modelsfakes/fake_vcs_backend.go +++ b/pkg/sourcetool/models/modelsfakes/fake_vcs_backend.go @@ -35,6 +35,25 @@ type FakeVcsBackend struct { controlConfigurationDescrReturnsOnCall map[int]struct { result1 string } + ControlPrecheckStub func(*models.Repository, []*models.Branch, models.ControlConfiguration) (bool, string, models.ControlPreRemediationFn, error) + controlPrecheckMutex sync.RWMutex + controlPrecheckArgsForCall []struct { + arg1 *models.Repository + arg2 []*models.Branch + arg3 models.ControlConfiguration + } + controlPrecheckReturns struct { + result1 bool + result2 string + result3 models.ControlPreRemediationFn + result4 error + } + controlPrecheckReturnsOnCall map[int]struct { + result1 bool + result2 string + result3 models.ControlPreRemediationFn + result4 error + } GetBranchControlsStub func(context.Context, *models.Repository, *models.Branch) (*slsa.ControlSetStatus, error) getBranchControlsMutex sync.RWMutex getBranchControlsArgsForCall []struct { @@ -234,6 +253,83 @@ func (fake *FakeVcsBackend) ControlConfigurationDescrReturnsOnCall(i int, result }{result1} } +func (fake *FakeVcsBackend) ControlPrecheck(arg1 *models.Repository, arg2 []*models.Branch, arg3 models.ControlConfiguration) (bool, string, models.ControlPreRemediationFn, error) { + var arg2Copy []*models.Branch + if arg2 != nil { + arg2Copy = make([]*models.Branch, len(arg2)) + copy(arg2Copy, arg2) + } + fake.controlPrecheckMutex.Lock() + ret, specificReturn := fake.controlPrecheckReturnsOnCall[len(fake.controlPrecheckArgsForCall)] + fake.controlPrecheckArgsForCall = append(fake.controlPrecheckArgsForCall, struct { + arg1 *models.Repository + arg2 []*models.Branch + arg3 models.ControlConfiguration + }{arg1, arg2Copy, arg3}) + stub := fake.ControlPrecheckStub + fakeReturns := fake.controlPrecheckReturns + fake.recordInvocation("ControlPrecheck", []interface{}{arg1, arg2Copy, arg3}) + fake.controlPrecheckMutex.Unlock() + if stub != nil { + return stub(arg1, arg2, arg3) + } + if specificReturn { + return ret.result1, ret.result2, ret.result3, ret.result4 + } + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4 +} + +func (fake *FakeVcsBackend) ControlPrecheckCallCount() int { + fake.controlPrecheckMutex.RLock() + defer fake.controlPrecheckMutex.RUnlock() + return len(fake.controlPrecheckArgsForCall) +} + +func (fake *FakeVcsBackend) ControlPrecheckCalls(stub func(*models.Repository, []*models.Branch, models.ControlConfiguration) (bool, string, models.ControlPreRemediationFn, error)) { + fake.controlPrecheckMutex.Lock() + defer fake.controlPrecheckMutex.Unlock() + fake.ControlPrecheckStub = stub +} + +func (fake *FakeVcsBackend) ControlPrecheckArgsForCall(i int) (*models.Repository, []*models.Branch, models.ControlConfiguration) { + fake.controlPrecheckMutex.RLock() + defer fake.controlPrecheckMutex.RUnlock() + argsForCall := fake.controlPrecheckArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + +func (fake *FakeVcsBackend) ControlPrecheckReturns(result1 bool, result2 string, result3 models.ControlPreRemediationFn, result4 error) { + fake.controlPrecheckMutex.Lock() + defer fake.controlPrecheckMutex.Unlock() + fake.ControlPrecheckStub = nil + fake.controlPrecheckReturns = struct { + result1 bool + result2 string + result3 models.ControlPreRemediationFn + result4 error + }{result1, result2, result3, result4} +} + +func (fake *FakeVcsBackend) ControlPrecheckReturnsOnCall(i int, result1 bool, result2 string, result3 models.ControlPreRemediationFn, result4 error) { + fake.controlPrecheckMutex.Lock() + defer fake.controlPrecheckMutex.Unlock() + fake.ControlPrecheckStub = nil + if fake.controlPrecheckReturnsOnCall == nil { + fake.controlPrecheckReturnsOnCall = make(map[int]struct { + result1 bool + result2 string + result3 models.ControlPreRemediationFn + result4 error + }) + } + fake.controlPrecheckReturnsOnCall[i] = struct { + result1 bool + result2 string + result3 models.ControlPreRemediationFn + result4 error + }{result1, result2, result3, result4} +} + func (fake *FakeVcsBackend) GetBranchControls(arg1 context.Context, arg2 *models.Repository, arg3 *models.Branch) (*slsa.ControlSetStatus, error) { fake.getBranchControlsMutex.Lock() ret, specificReturn := fake.getBranchControlsReturnsOnCall[len(fake.getBranchControlsArgsForCall)] diff --git a/pkg/sourcetool/tool.go b/pkg/sourcetool/tool.go index d9f9cad7..ccdc7a02 100644 --- a/pkg/sourcetool/tool.go +++ b/pkg/sourcetool/tool.go @@ -263,3 +263,17 @@ func (t *Tool) CreatePolicyRepoFork(ctx context.Context) error { } return nil } + +// ControlPrecheck performs a prerequisite check before enabling a contrlol +// Backend may optionally return a remediation function to correct the +// prerequisite which the CLI can before attempting to enable the control. +func (t *Tool) ControlPrecheck( + r *models.Repository, branches []*models.Branch, config models.ControlConfiguration, +) (ok bool, remediationMessage string, remediateFn models.ControlPreRemediationFn, err error) { + backend, err := t.impl.GetVcsBackend(r) + if err != nil { + return false, "", nil, fmt.Errorf("getting VCS backend: %w", err) + } + + return backend.ControlPrecheck(r, branches, config) +}