Skip to content

Commit

Permalink
Allow to set protected file patterns that can not be changed under no…
Browse files Browse the repository at this point in the history
… conditions

Co-Authored-By: zeripath <art27@cantab.net>
  • Loading branch information
lafriks and zeripath committed Mar 26, 2020
1 parent b1c331c commit f6a2483
Show file tree
Hide file tree
Showing 15 changed files with 202 additions and 23 deletions.
19 changes: 19 additions & 0 deletions models/branches.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package models
import (
"context"
"fmt"
"strings"
"time"

"code.gitea.io/gitea/modules/base"
Expand All @@ -15,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/timeutil"
"code.gitea.io/gitea/modules/util"

"github.com/gobwas/glob"
"github.com/unknwon/com"
)

Expand Down Expand Up @@ -47,6 +49,7 @@ type ProtectedBranch struct {
BlockOnRejectedReviews bool `xorm:"NOT NULL DEFAULT false"`
DismissStaleApprovals bool `xorm:"NOT NULL DEFAULT false"`
RequireSignedCommits bool `xorm:"NOT NULL DEFAULT false"`
ProtectedFilePatterns string `xorm:"TEXT"`

CreatedUnix timeutil.TimeStamp `xorm:"created"`
UpdatedUnix timeutil.TimeStamp `xorm:"updated"`
Expand Down Expand Up @@ -190,6 +193,22 @@ func (protectBranch *ProtectedBranch) MergeBlockedByRejectedReview(pr *PullReque
return rejectExist
}

// GetProtectedFilePatterns parses a semicolon separated list of protected file patterns and returns a glob.Glob slice
func (protectBranch *ProtectedBranch) GetProtectedFilePatterns() []glob.Glob {
extarr := make([]glob.Glob, 0, 10)
for _, expr := range strings.Split(strings.ToLower(protectBranch.ProtectedFilePatterns), ";") {
expr = strings.TrimSpace(expr)
if expr != "" {
if g, err := glob.Compile(expr, '.', '/'); err != nil {
log.Info("Invalid glob expresion '%s' (skipped): %v", expr, err)
} else {
extarr = append(extarr, g)
}
}
}
return extarr
}

// GetProtectedBranchByRepoID getting protected branch by repo ID
func GetProtectedBranchByRepoID(repoID int64) ([]*ProtectedBranch, error) {
protectedBranches := make([]*ProtectedBranch, 0)
Expand Down
19 changes: 19 additions & 0 deletions models/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,25 @@ func (err ErrFilePathInvalid) Error() string {
return fmt.Sprintf("path is invalid [path: %s]", err.Path)
}

// ErrFilePathProtected represents a "FilePathProtected" kind of error.
type ErrFilePathProtected struct {
Message string
Path string
}

// IsErrFilePathProtected checks if an error is an ErrFilePathProtected.
func IsErrFilePathProtected(err error) bool {
_, ok := err.(ErrFilePathProtected)
return ok
}

func (err ErrFilePathProtected) Error() string {
if err.Message != "" {
return err.Message
}
return fmt.Sprintf("path is protected and can not be changed [path: %s]", err.Path)
}

// ErrUserDoesNotHaveAccessToRepo represets an error where the user doesn't has access to a given repo.
type ErrUserDoesNotHaveAccessToRepo struct {
UserID int64
Expand Down
2 changes: 2 additions & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ var migrations = []Migration{
NewMigration("Expand webhooks for more granularity", expandWebhooks),
// v131 -> v132
NewMigration("Add IsSystemWebhook column to webhooks table", addSystemWebhookColumn),
// v132 -> v133
NewMigration("Add Branch Protection Protected Files Column", addBranchProtectionProtectedFilesColumn),
}

// Migrate database to current version
Expand Down
22 changes: 22 additions & 0 deletions models/migrations/v132.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// Use of this source code is governed by a MIT-style
// license that can be found in the LICENSE file.

package migrations

import (
"fmt"

"xorm.io/xorm"
)

func addBranchProtectionProtectedFilesColumn(x *xorm.Engine) error {
type ProtectedBranch struct {
ProtectedFilePatterns string `xorm:"TEXT"`
}

if err := x.Sync2(new(ProtectedBranch)); err != nil {
return fmt.Errorf("Sync2: %v", err)
}
return nil
}
1 change: 1 addition & 0 deletions modules/auth/repo_form.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ type ProtectBranchForm struct {
BlockOnRejectedReviews bool
DismissStaleApprovals bool
RequireSignedCommits bool
ProtectedFilePatterns string
}

// Validate validates the fields
Expand Down
1 change: 1 addition & 0 deletions modules/convert/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ func ToBranchProtection(bp *models.ProtectedBranch) *api.BranchProtection {
BlockOnRejectedReviews: bp.BlockOnRejectedReviews,
DismissStaleApprovals: bp.DismissStaleApprovals,
RequireSignedCommits: bp.RequireSignedCommits,
ProtectedFilePatterns: bp.ProtectedFilePatterns,
Created: bp.CreatedUnix.AsTime(),
Updated: bp.UpdatedUnix.AsTime(),
}
Expand Down
32 changes: 21 additions & 11 deletions modules/repofiles/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,21 +60,31 @@ func DeleteRepoFile(repo *models.Repository, doer *models.User, opts *DeleteRepo
if err != nil {
return nil, err
}
if protectedBranch != nil && !protectedBranch.CanUserPush(doer.ID) {
return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
if protectedBranch != nil && protectedBranch.RequireSignedCommits {
_, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
if err != nil {
if !models.IsErrWontSign(err) {
return nil, err
}
if protectedBranch != nil {
if !protectedBranch.CanUserPush(doer.ID) {
return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
if protectedBranch.RequireSignedCommits {
_, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
if err != nil {
if !models.IsErrWontSign(err) {
return nil, err
}
return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
}
patterns := protectedBranch.GetProtectedFilePatterns()
for _, pat := range patterns {
if pat.Match(strings.ToLower(opts.TreePath)) {
return nil, models.ErrFilePathProtected{
Path: opts.TreePath,
}
}
}
}
}

Expand Down
32 changes: 21 additions & 11 deletions modules/repofiles/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,21 +156,31 @@ func CreateOrUpdateRepoFile(repo *models.Repository, doer *models.User, opts *Up
if err != nil {
return nil, err
}
if protectedBranch != nil && !protectedBranch.CanUserPush(doer.ID) {
return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
if protectedBranch != nil && protectedBranch.RequireSignedCommits {
_, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
if err != nil {
if !models.IsErrWontSign(err) {
return nil, err
}
if protectedBranch != nil {
if !protectedBranch.CanUserPush(doer.ID) {
return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
if protectedBranch.RequireSignedCommits {
_, _, err := repo.SignCRUDAction(doer, repo.RepoPath(), opts.OldBranch)
if err != nil {
if !models.IsErrWontSign(err) {
return nil, err
}
return nil, models.ErrUserCannotCommit{
UserName: doer.LowerName,
}
}
}
patterns := protectedBranch.GetProtectedFilePatterns()
for _, pat := range patterns {
if pat.Match(strings.ToLower(opts.TreePath)) {
return nil, models.ErrFilePathProtected{
Path: opts.TreePath,
}
}
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions modules/structs/repo_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type BranchProtection struct {
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
RequireSignedCommits bool `json:"require_signed_commits"`
ProtectedFilePatterns string `json:"protected_file_patterns"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
// swagger:strfmt date-time
Expand All @@ -67,6 +68,7 @@ type CreateBranchProtectionOption struct {
BlockOnRejectedReviews bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals bool `json:"dismiss_stale_approvals"`
RequireSignedCommits bool `json:"require_signed_commits"`
ProtectedFilePatterns string `json:"protected_file_patterns"`
}

// EditBranchProtectionOption options for editing a branch protection
Expand All @@ -88,4 +90,5 @@ type EditBranchProtectionOption struct {
BlockOnRejectedReviews *bool `json:"block_on_rejected_reviews"`
DismissStaleApprovals *bool `json:"dismiss_stale_approvals"`
RequireSignedCommits *bool `json:"require_signed_commits"`
ProtectedFilePatterns *string `json:"protected_file_patterns"`
}
2 changes: 2 additions & 0 deletions options/locale/locale_en-US.ini
Original file line number Diff line number Diff line change
Expand Up @@ -1488,6 +1488,8 @@ settings.dismiss_stale_approvals = Dismiss stale approvals
settings.dismiss_stale_approvals_desc = When new commits that change the content of the pull request are pushed to the branch, old approvals will be dismissed.
settings.require_signed_commits = Require Signed Commits
settings.require_signed_commits_desc = Reject pushes to this branch if they are unsigned or unverifiable
settings.protect_protected_file_patterns = Protected file patterns (separated using semicolon ';'):
settings.protect_protected_file_patterns_desc = Protected files that are not allowed to be changed directly even if user has rights to add, edit or delete files in this branch. Multiple patterns can be separated using semicolon (';'). See <a href="https://godoc.org/github.com/gobwas/glob#Compile">github.com/gobwas/glob</a> documentation for pattern syntax. Examples: <code>.drone.yml</code>, <code>/docs/**/*.txt</code>.
settings.add_protected_branch = Enable protection
settings.delete_protected_branch = Disable protection
settings.update_protect_branch_success = Branch protection for branch '%s' has been updated.
Expand Down
5 changes: 5 additions & 0 deletions routers/api/v1/repo/branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,7 @@ func CreateBranchProtection(ctx *context.APIContext, form api.CreateBranchProtec
BlockOnRejectedReviews: form.BlockOnRejectedReviews,
DismissStaleApprovals: form.DismissStaleApprovals,
RequireSignedCommits: form.RequireSignedCommits,
ProtectedFilePatterns: form.ProtectedFilePatterns,
}

err = models.UpdateProtectBranch(ctx.Repo.Repository, protectBranch, models.WhitelistOptions{
Expand Down Expand Up @@ -470,6 +471,10 @@ func EditBranchProtection(ctx *context.APIContext, form api.EditBranchProtection
protectBranch.RequireSignedCommits = *form.RequireSignedCommits
}

if form.ProtectedFilePatterns != nil {
protectBranch.ProtectedFilePatterns = *form.ProtectedFilePatterns
}

var whitelistUsers []int64
if form.PushWhitelistUsernames != nil {
whitelistUsers, err = models.GetUserIDsByNames(form.PushWhitelistUsernames, false)
Expand Down
69 changes: 68 additions & 1 deletion routers/private/hook.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@ import (
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
pull_service "code.gitea.io/gitea/services/pull"
"github.com/go-git/go-git/v5/plumbing"

"gitea.com/macaron/macaron"
"github.com/go-git/go-git/v5/plumbing"
"github.com/gobwas/glob"
)

func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []string) error {
Expand Down Expand Up @@ -57,6 +58,52 @@ func verifyCommits(oldCommitID, newCommitID string, repo *git.Repository, env []
return err
}

func checkFileProtection(oldCommitID, newCommitID string, patterns []glob.Glob, repo *git.Repository, env []string) error {

stdoutReader, stdoutWriter, err := os.Pipe()
if err != nil {
log.Error("Unable to create os.Pipe for %s", repo.Path)
return err
}
defer func() {
_ = stdoutReader.Close()
_ = stdoutWriter.Close()
}()

err = git.NewCommand("diff", "--name-only", oldCommitID+"..."+newCommitID).
RunInDirTimeoutEnvFullPipelineFunc(env, -1, repo.Path,
stdoutWriter, nil, nil,
func(ctx context.Context, cancel context.CancelFunc) error {
_ = stdoutWriter.Close()

scanner := bufio.NewScanner(stdoutReader)
for scanner.Scan() {
path := strings.TrimSpace(scanner.Text())
if len(path) == 0 {
continue
}
lpath := strings.ToLower(path)
for _, pat := range patterns {
if pat.Match(lpath) {
cancel()
return models.ErrFilePathProtected{
Path: path,
}
}
}
}
if err := scanner.Err(); err != nil {
return err
}
_ = stdoutReader.Close()
return err
})
if err != nil && !models.IsErrFilePathProtected(err) {
log.Error("Unable to check file protection for commits from %s to %s in %s: %v", oldCommitID, newCommitID, repo.Path, err)
}
return err
}

func readAndVerifyCommitsFromShaReader(input io.ReadCloser, repo *git.Repository, env []string) error {
scanner := bufio.NewScanner(input)
for scanner.Scan() {
Expand Down Expand Up @@ -216,6 +263,26 @@ func HookPreReceive(ctx *macaron.Context, opts private.HookOptions) {
}
}

globs := protectBranch.GetProtectedFilePatterns()
if len(globs) > 0 {
err := checkFileProtection(oldCommitID, newCommitID, globs, gitRepo, env)
if err != nil {
if !models.IsErrFilePathProtected(err) {
log.Error("Unable to check file protection for commits from %s to %s in %-v: %v", oldCommitID, newCommitID, repo, err)
ctx.JSON(http.StatusInternalServerError, map[string]interface{}{
"err": fmt.Sprintf("Unable to check file protection for commits from %s to %s: %v", oldCommitID, newCommitID, err),
})
return
}
protectedFilePath := err.(models.ErrFilePathProtected).Path
log.Warn("Forbidden: Branch: %s in %-v is protected from changing file %s", branchName, repo, protectedFilePath)
ctx.JSON(http.StatusForbidden, map[string]interface{}{
"err": fmt.Sprintf("branch %s is protected from changing file %s", branchName, protectedFilePath),
})
return
}
}

canPush := false
if opts.IsDeployKey {
canPush = protectBranch.CanPush && (!protectBranch.EnableWhitelist || protectBranch.WhitelistDeployKeys)
Expand Down
1 change: 1 addition & 0 deletions routers/repo/setting_protected_branch.go
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ func SettingsProtectedBranchPost(ctx *context.Context, f auth.ProtectBranchForm)
protectBranch.BlockOnRejectedReviews = f.BlockOnRejectedReviews
protectBranch.DismissStaleApprovals = f.DismissStaleApprovals
protectBranch.RequireSignedCommits = f.RequireSignedCommits
protectBranch.ProtectedFilePatterns = f.ProtectedFilePatterns

err = models.UpdateProtectBranch(ctx.Repo.Repository, protectBranch, models.WhitelistOptions{
UserIDs: whitelistUsers,
Expand Down
5 changes: 5 additions & 0 deletions templates/repo/settings/protected_branch.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,11 @@
<p class="help">{{.i18n.Tr "repo.settings.require_signed_commits_desc"}}</p>
</div>
</div>
<div class="field">
<label for="protected_file_patterns">{{.i18n.Tr "repo.settings.protect_protected_file_patterns"}}</label>
<input name="protected_file_patterns" id="protected_file_patterns" type="text" value="{{.Branch.ProtectedFilePatterns}}">
<p class="help">{{.i18n.Tr "repo.settings.protect_protected_file_patterns_desc" | Safe}}</p>
</div>

</div>

Expand Down
Loading

0 comments on commit f6a2483

Please sign in to comment.