diff --git a/README.md b/README.md index 4c3bf8d1..c103506d 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ Also it searches for hooks in configured shared hook repositories. - [Shared Repository Namespace](#shared-repository-namespace) - [Ignoring Hooks and Files](#ignoring-hooks-and-files) - [Trusting Hooks](#trusting-hooks) + - [Trusted Remotes](#trusted-remotes) - [Disabling Githooks](#disabling-githooks) - [Environment Variables](#environment-variables) - [Arguments to Shared Hooks](#arguments-to-shared-hooks) @@ -644,6 +645,41 @@ for more information. You can also trust individual hooks by using [`git hooks trust hooks --help`](docs/cli/git_hooks_trust_hooks.md). +### Trusted Remotes + +If you trust all hooks coming from certain remotes, e.g. all repositories of +your own organization, you can add glob patterns which are matched against the +url of the remote `origin` of a repository: + +```shell +# Trust all repositories of an organization (for all repositories): +$ git hooks config trusted-remotes --global --add \ + 'https://github.com/my-org/**' 'git@github.com:my-org/**' +# Show the patterns and if the current repository matches any of them: +$ git hooks config trusted-remotes --print +# Remove all patterns again: +$ git hooks config trusted-remotes --global --reset +``` + +Every repository whose remote url matches any of these patterns is a trusted +repository, meaning **no trust prompt is shown** and the trust marker file +`/.githooks/trust-all` is not needed. Consult +[`git hooks config trusted-remotes --help`](docs/cli/git_hooks_config_trusted-remotes.md) +for more information. Note the following: + +- The url is matched as configured in `remote.origin.url`, meaning + `https://github.com/my-org/repo.git` and `git@github.com:my-org/repo.git` are + different urls which need separate patterns. A repository without a remote + `origin` is never trusted. +- The separator is always `/`, therefore `*` does not match over `/` but `**` + does. +- A repository whose trust setting was explicitly set by the user (see + [`git hooks config trust-all`](docs/cli/git_hooks_config_trust-all.md)) is not + affected by these patterns, meaning a denied repository stays untrusted. +- Since all current **and future** hooks of matching repositories run without + any confirmation, only add remotes you fully trust: anybody who can push hooks + to such a repository can execute code on your machine. + ## Disabling Githooks To disable running any Githooks locally or globally, use the following: diff --git a/docs/cli/git_hooks_config.md b/docs/cli/git_hooks_config.md index 93057613..9d1af472 100644 --- a/docs/cli/git_hooks_config.md +++ b/docs/cli/git_hooks_config.md @@ -47,6 +47,8 @@ git hooks config Enable/disable skipping active, untrusted hooks. - [git hooks config trust-all](git_hooks_config_trust-all.md) - Change trust settings in the current repository. +- [git hooks config trusted-remotes](git_hooks_config_trusted-remotes.md) - + Updates the list of trusted remotes. - [git hooks config update-check](git_hooks_config_update-check.md) - Change Githooks update-check settings. - [git hooks config update-time](git_hooks_config_update-time.md) - Changes the diff --git a/docs/cli/git_hooks_config_trusted-remotes.md b/docs/cli/git_hooks_config_trusted-remotes.md new file mode 100644 index 00000000..23531e2c --- /dev/null +++ b/docs/cli/git_hooks_config_trusted-remotes.md @@ -0,0 +1,47 @@ +## git hooks config trusted-remotes + +Updates the list of trusted remotes. + +### Synopsis + +Updates the list of glob patterns which are matched against the url of the +remote `origin` of a repository. + +Every repository whose remote url matches any of these patterns trusts all its +current and future hooks automatically, meaning no trust prompt is shown and the +trust marker file `/.githooks/trust-all` is not needed. + +The url is matched as configured in `remote.origin.url`, meaning +`https://github.com/org/repo.git` and 'git@github.com:org/repo.git' are +different urls which may need separate patterns. The separator is always `/`, +therefore '\*' does not match over `/` but '\*\*' does. + +A repository whose trust setting was explicitly set by the user (see +`git hooks config trust-all`) is not affected by these patterns. + +Only add remotes whose current and future hooks you fully trust, since Githooks +will run them without any confirmation. + +The `--add` option accepts multiple `` arguments. + +``` +git hooks config trusted-remotes [flags] [...] +``` + +### Options + +``` + --local Use the local Git configuration. + --global Use the global Git configuration (default). + --print Print the setting. + --add Adds given trusted remote patterns ``s. + --reset Reset the setting. + -h, --help help for trusted-remotes +``` + +### SEE ALSO + +- [git hooks config](git_hooks_config.md) - Manages various Githooks + configuration. + +###### Auto generated by spf13/cobra diff --git a/githooks/cmd/config/config.go b/githooks/cmd/config/config.go index 663c507b..e2c232b5 100644 --- a/githooks/cmd/config/config.go +++ b/githooks/cmd/config/config.go @@ -331,6 +331,67 @@ func runSharedRepos(ctx *ccm.CmdContext, opts *SetOptions, gitOpts *GitOptions) } } +func runTrustedRemotes(ctx *ccm.CmdContext, opts *SetOptions, gitOpts *GitOptions) { + opt := hooks.GitCKTrustedRemotes + + localOrGlobal := "local" + if gitOpts.Global { + localOrGlobal = "global" + } + + switch { + case opts.Set: + scope := wrapToGitScope(ctx.Log, gitOpts) + for i := range opts.Values { + err := ctx.GitX.AddConfig(opt, opts.Values[i], scope) + ctx.Log.AssertNoErrorPanicF(err, "Could not add %s trusted remote.", localOrGlobal) + } + ctx.Log.InfoF("Added '%v' %s trusted remotes.", len(opts.Values), localOrGlobal) + + case opts.Reset: + scope := wrapToGitScope(ctx.Log, gitOpts) + err := ctx.GitX.UnsetConfig(opt, scope) + ctx.Log.AssertNoErrorPanicF(err, "Could not unset %s trusted remotes.", localOrGlobal) + ctx.Log.InfoF("Removed all %s trusted remotes.", localOrGlobal) + + case opts.Print: + list := func(p []string) string { + if len(p) == 0 { + return "[0]: none" + } + + return strs.Fmt("[%v]:\n%s", len(p), + strings.Join(strs.Map(p, + func(s string) string { return strs.Fmt("%s '%s'", cm.ListItemLiteral, s) }), + "\n")) + } + + if gitOpts.Local { + ctx.Log.InfoF("Local trusted remotes %s", + list(ctx.GitX.GetConfigAll(opt, git.LocalScope))) + } + + if gitOpts.Global { + ctx.Log.InfoF("Global trusted remotes %s", + list(ctx.GitX.GetConfigAll(opt, git.GlobalScope))) + } + + // Report the effect on the current repository, if we are inside one. + if _, _, _, err := ctx.GitX.GetRepoRoot(); err == nil { + if isTrusted, pattern := hooks.IsRemoteTrusted(ctx.GitX); isTrusted { + ctx.Log.InfoF( + "The current repository is trusted by pattern '%s'.", pattern) + } else { + ctx.Log.Info( + "The current repository is not trusted by any trusted remote.") + } + } + + default: + cm.Panic("Wrong arguments.") + } +} + func runCloneURL(ctx *ccm.CmdContext, opts *SetOptions) { switch { case opts.Set: @@ -933,6 +994,58 @@ each containing a clone URL of a shared hook repository which gets added.`, configCmd.AddCommand(ccm.SetCommandDefaults(ctx.Log, sharedCmd)) } +func configTrustedRemotesCmd( + ctx *ccm.CmdContext, + configCmd *cobra.Command, + setOpts *SetOptions, + gitOpts *GitOptions, +) { + trustedRemotesCmd := &cobra.Command{ + Use: "trusted-remotes [flags] [...]", + Short: "Updates the list of trusted remotes.", + Long: `Updates the list of glob patterns which are matched against +the url of the remote '` + hooks.TrustedRemoteName + `' of a repository. + +Every repository whose remote url matches any of these patterns trusts all +its current and future hooks automatically, meaning no trust prompt is shown +and the trust marker file '/` + hooks.HooksDirName + `/trust-all' is not needed. + +The url is matched as configured in 'remote.` + hooks.TrustedRemoteName + `.url', +meaning 'https://github.com/org/repo.git' and 'git@github.com:org/repo.git' are +different urls which may need separate patterns. The separator is always '/', +therefore '*' does not match over '/' but '**' does. + +A repository whose trust setting was explicitly set by the user +(see 'git hooks config trust-all') is not affected by these patterns. + +Only add remotes whose current and future hooks you fully trust, since +Githooks will run them without any confirmation. + +The '--add' option accepts multiple '' arguments.`, + Run: func(cmd *cobra.Command, args []string) { + if !gitOpts.Local && !gitOpts.Global { + _, _, _, err := ctx.GitX.GetRepoRoot() + gitOpts.Global = true + gitOpts.Local = setOpts.Print && err == nil + } else if gitOpts.Local { + ccm.AssertRepoRoot(ctx) + } + + runTrustedRemotes(ctx, setOpts, gitOpts) + }} + + optsPSR := createOptionMap(true, false, true) + optsPSR.Set = "add" + optsPSR.SetDesc = "Adds given trusted remote patterns ''s." + trustedRemotesCmd.Flags(). + BoolVar(&gitOpts.Local, "local", false, "Use the local Git configuration.") + trustedRemotesCmd.Flags(). + BoolVar(&gitOpts.Global, "global", false, "Use the global Git configuration (default).") + + configSetOptions(trustedRemotesCmd, setOpts, &optsPSR, ctx.Log, 1, -1) + configCmd.AddCommand(ccm.SetCommandDefaults(ctx.Log, trustedRemotesCmd)) +} + func configSkipNonExistingSharedHooks( ctx *ccm.CmdContext, configCmd *cobra.Command, @@ -1142,6 +1255,8 @@ func NewCmd(ctx *ccm.CmdContext) *cobra.Command { configSharedCmd(ctx, configCmd, &setOpts, &gitOpts) configDisableSharedHooksUpdate(ctx, configCmd, &setOpts, &gitOpts) + configTrustedRemotesCmd(ctx, configCmd, &setOpts, &gitOpts) + configSkipNonExistingSharedHooks(ctx, configCmd, &setOpts, &gitOpts) configFailUntrustedHooks(ctx, configCmd, &setOpts, &gitOpts) diff --git a/githooks/common/glob.go b/githooks/common/glob.go index cbfe4126..f8b4854a 100644 --- a/githooks/common/glob.go +++ b/githooks/common/glob.go @@ -29,6 +29,14 @@ func GlobMatch(pattern string, path string) (bool, error) { return glob.Match(pattern, path) } +// GlobMatchSlashes matches a pattern against a string which is always +// separated by forward slashes `/`, such as an url. +// In contrast to `GlobMatch` the result does not depend on the platforms +// path separator, meaning `*` never matches over `/` and `**` does. +func GlobMatchSlashes(pattern string, s string) (bool, error) { + return glob.Match(pattern, s) +} + // Globs represents one filepath glob, with its elements joined by "**". type globs []string diff --git a/githooks/hooks/gitconfig.go b/githooks/hooks/gitconfig.go index dddb6070..2a08199b 100644 --- a/githooks/hooks/gitconfig.go +++ b/githooks/hooks/gitconfig.go @@ -48,6 +48,8 @@ const ( GitCKSkipNonExistingSharedHooks = "githooks.skipNonExistingSharedHooks" GitCKSkipUntrustedHooks = "githooks.skipUntrustedHooks" + GitCKTrustedRemotes = "githooks.trustedRemotes" + GitCKRunnerIsNonInteractive = "githooks.runnerIsNonInteractive" GitCKContainerizedHooksEnabled = "githooks.containerizedHooksEnabled" @@ -96,6 +98,8 @@ func GetGlobalGitConfigKeys() []string { GitCKSkipNonExistingSharedHooks, GitCKSkipUntrustedHooks, + GitCKTrustedRemotes, + GitCKRunnerIsNonInteractive, GitCKContainerManager, @@ -120,6 +124,8 @@ func GetLocalGitConfigKeys() []string { GitCKSkipNonExistingSharedHooks, GitCKSkipUntrustedHooks, + GitCKTrustedRemotes, + GitCKRunnerIsNonInteractive, GitCKContainerManager, diff --git a/githooks/hooks/trusted.go b/githooks/hooks/trusted.go index 8c79fb03..99f5a704 100644 --- a/githooks/hooks/trusted.go +++ b/githooks/hooks/trusted.go @@ -35,9 +35,73 @@ func SetTrustAllSetting(gitx *git.Context, enable bool, reset bool) error { } } +// TrustedRemoteName is the name of the remote whose url is matched +// against the trusted remote patterns (see `IsRemoteTrusted`). +const TrustedRemoteName = "origin" + +// GetTrustedRemotes gets the trusted remote url patterns in `scope`. +func GetTrustedRemotes(gitx *git.Context, scope git.ConfigScope) []string { + return gitx.GetConfigAll(GitCKTrustedRemotes, scope) +} + +// matchesTrustedRemote reports if `url` matches any glob pattern in +// `patterns` together with the first pattern which matched. +// An empty `url` never matches, such that a repository without a +// remote is never trusted by a pattern like `*`. +func matchesTrustedRemote(patterns []string, url string) (isTrusted bool, pattern string) { + if strs.IsEmpty(url) { + return + } + + for _, p := range patterns { + if strs.IsEmpty(p) { + continue + } + + // Urls are always separated by `/`, therefore match + // platform independent of the path separator. + matched, err := cm.GlobMatchSlashes(p, url) + cm.DebugAssertNoErrorF(err, "Malformed trusted remote pattern '%s'.", p) + + if err != nil { + continue + } + + if matched { + return true, p + } + } + + return +} + +// IsRemoteTrusted tells if the url of the remote `TrustedRemoteName` of the +// current repository matches any pattern in the trusted remotes +// configuration `GitCKTrustedRemotes` together with the pattern which matched. +// The url is matched as configured, meaning e.g. `https://` and `ssh://` urls +// of the same repository need separate patterns. +func IsRemoteTrusted(gitx *git.Context) (isTrusted bool, pattern string) { + patterns := GetTrustedRemotes(gitx, git.Traverse) + if len(patterns) == 0 { + return + } + + return matchesTrustedRemote( + patterns, + gitx.GetConfig("remote."+TrustedRemoteName+".url", git.LocalScope)) +} + // IsRepoTrusted tells if the repository `repoPath` is trusted. -// It is only trusted if the trust marker is present and -// the `trustAll` settings is set to `trusted`. +// It is trusted if either +// - the trust marker is present and the `trustAll` setting is set to +// `trusted`, or +// - the url of the remote `TrustedRemoteName` matches any trusted remote +// pattern (see `IsRemoteTrusted`), which needs neither the trust marker +// nor any user interaction. +// +// An explicit `trustAll` setting in the repository always takes precedence +// over the trusted remotes configuration, meaning a repository whose trust +// was denied by the user stays untrusted. // On any error `false` is reported together with the error. func IsRepoTrusted( gitx *git.Context, @@ -49,6 +113,12 @@ func IsRepoTrusted( isTrusted, trustAllSet = GetTrustAllSetting(gitx) } + if isTrusted || trustAllSet { + return + } + + isTrusted, _ = IsRemoteTrusted(gitx) + return } diff --git a/githooks/hooks/trusted_test.go b/githooks/hooks/trusted_test.go new file mode 100644 index 00000000..97d96bb4 --- /dev/null +++ b/githooks/hooks/trusted_test.go @@ -0,0 +1,249 @@ +package hooks + +import ( + "os" + "os/exec" + "path" + "testing" + + cm "github.com/gabyx/githooks/githooks/common" + "github.com/gabyx/githooks/githooks/git" + strs "github.com/gabyx/githooks/githooks/strings" + "github.com/stretchr/testify/assert" +) + +func TestTrustedRemoteMatch(t *testing.T) { + patterns := []string{"https://github.com/my-org/**"} + + isTrusted, pattern := matchesTrustedRemote( + patterns, "https://github.com/my-org/my-repo.git") + assert.True(t, isTrusted) + assert.Equal(t, patterns[0], pattern) + + // Other organizations must not match. + isTrusted, _ = matchesTrustedRemote( + patterns, "https://github.com/other-org/my-repo.git") + assert.False(t, isTrusted) + + // Other hosts must not match. + isTrusted, _ = matchesTrustedRemote( + patterns, "https://gitlab.com/my-org/my-repo.git") + assert.False(t, isTrusted) +} + +func TestTrustedRemoteMatchSeparator(t *testing.T) { + // `*` does not match over `/`, `**` does. + single := []string{"https://github.com/my-org/*"} + double := []string{"https://github.com/my-org/**"} + + isTrusted, _ := matchesTrustedRemote(single, "https://github.com/my-org/my-repo.git") + assert.True(t, isTrusted) + + isTrusted, _ = matchesTrustedRemote(single, "https://github.com/my-org/sub/my-repo.git") + assert.False(t, isTrusted) + + isTrusted, _ = matchesTrustedRemote(double, "https://github.com/my-org/sub/my-repo.git") + assert.True(t, isTrusted) +} + +func TestTrustedRemoteMatchScpSyntax(t *testing.T) { + // A `https://` pattern must not match the scp syntax url of the + // same repository and vice versa. + https := []string{"https://github.com/my-org/**"} + scp := []string{"git@github.com:my-org/**"} + + isTrusted, _ := matchesTrustedRemote(https, "git@github.com:my-org/my-repo.git") + assert.False(t, isTrusted) + + isTrusted, _ = matchesTrustedRemote(scp, "git@github.com:my-org/my-repo.git") + assert.True(t, isTrusted) + + isTrusted, _ = matchesTrustedRemote(scp, "https://github.com/my-org/my-repo.git") + assert.False(t, isTrusted) +} + +func TestTrustedRemoteMatchNoRemote(t *testing.T) { + // A repository without a remote url must never be trusted, + // also not by patterns matching everything. + isTrusted, _ := matchesTrustedRemote([]string{"*"}, "") + assert.False(t, isTrusted) + + isTrusted, _ = matchesTrustedRemote([]string{"**"}, "") + assert.False(t, isTrusted) +} + +func TestTrustedRemoteMatchNoPatterns(t *testing.T) { + isTrusted, _ := matchesTrustedRemote(nil, "https://github.com/my-org/my-repo.git") + assert.False(t, isTrusted) + + // Empty patterns are skipped and must not match. + isTrusted, _ = matchesTrustedRemote( + []string{"", " "}, "https://github.com/my-org/my-repo.git") + assert.False(t, isTrusted) +} + +func TestTrustedRemoteMatchNotAnchored(t *testing.T) { + patterns := []string{"https://github.com/my-org/**"} + + // The pattern is matched against the whole url, an url only + // containing it must not match. + isTrusted, _ := matchesTrustedRemote( + patterns, "https://evil.com/x?url=https://github.com/my-org/my-repo.git") + assert.False(t, isTrusted) + + // A similar looking host must not match. + isTrusted, _ = matchesTrustedRemote( + patterns, "https://github.com.evil.com/my-org/my-repo.git") + assert.False(t, isTrusted) +} + +func TestTrustedRemoteMatchFirstPattern(t *testing.T) { + patterns := []string{ + "https://github.com/other-org/**", + "https://github.com/my-org/**", + } + + isTrusted, pattern := matchesTrustedRemote( + patterns, "https://github.com/my-org/my-repo.git") + assert.True(t, isTrusted) + assert.Equal(t, patterns[1], pattern) +} + +// runGitIn runs Git inside `dir`. +func runGitIn(t *testing.T, dir string, args ...string) { + t.Helper() + + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + assert.NoError(t, err, "git %v failed: %s", args, string(out)) +} + +// makeRepo creates a repository with remote `origin` set to `originURL` +// (if not empty) and an isolated global and system Git configuration. +func makeRepo(t *testing.T, originURL string) string { + t.Helper() + + dir := t.TempDir() + globalConfig := path.Join(t.TempDir(), "gitconfig-global") + + // Isolate, such that the users configuration cannot influence the test. + t.Setenv("GIT_CONFIG_GLOBAL", globalConfig) + t.Setenv("GIT_CONFIG_SYSTEM", path.Join(t.TempDir(), "gitconfig-system")) + assert.NoError(t, os.WriteFile(globalConfig, []byte(""), cm.DefaultFileModeFile)) + + runGitIn(t, dir, "init") + + if strs.IsNotEmpty(originURL) { + runGitIn(t, dir, "config", "remote."+TrustedRemoteName+".url", originURL) + } + + return dir +} + +func makeTrustMarker(t *testing.T, dir string) { + t.Helper() + + assert.NoError(t, os.MkdirAll(path.Join(dir, HooksDirName), cm.DefaultFileModeDirectory)) + assert.NoError(t, os.WriteFile(GetTrustMarkerFile(dir), []byte(""), cm.DefaultFileModeFile)) +} + +// isRepoTrusted reports `IsRepoTrusted` without and with an initialized +// Git config cache, since the runner uses a cache. +func isRepoTrusted(t *testing.T, dir string) (uncached bool, cached bool) { + t.Helper() + + uncached, _, _ = IsRepoTrusted(git.NewCtxAt(dir), dir) + + gitx := git.NewCtxAt(dir) + assert.NoError(t, gitx.InitConfigCache(nil)) + cached, _, _ = IsRepoTrusted(gitx, dir) + + return +} + +func TestRepoTrustedByRemoteNotConfigured(t *testing.T) { + dir := makeRepo(t, "https://github.com/my-org/my-repo.git") + + uncached, cached := isRepoTrusted(t, dir) + assert.False(t, uncached) + assert.False(t, cached) +} + +func TestRepoTrustedByRemoteLocal(t *testing.T) { + dir := makeRepo(t, "https://github.com/my-org/my-repo.git") + runGitIn(t, dir, "config", "--add", GitCKTrustedRemotes, "https://github.com/my-org/**") + + // No trust marker and no user interaction is needed. + uncached, cached := isRepoTrusted(t, dir) + assert.True(t, uncached) + assert.True(t, cached) +} + +func TestRepoTrustedByRemoteGlobal(t *testing.T) { + dir := makeRepo(t, "https://github.com/my-org/my-repo.git") + runGitIn(t, dir, "config", "--global", "--add", + GitCKTrustedRemotes, "https://github.com/my-org/**") + + uncached, cached := isRepoTrusted(t, dir) + assert.True(t, uncached) + assert.True(t, cached) +} + +func TestRepoTrustedByRemoteOtherOrg(t *testing.T) { + dir := makeRepo(t, "https://github.com/other-org/my-repo.git") + runGitIn(t, dir, "config", "--global", "--add", + GitCKTrustedRemotes, "https://github.com/my-org/**") + + uncached, cached := isRepoTrusted(t, dir) + assert.False(t, uncached) + assert.False(t, cached) +} + +func TestRepoTrustedByRemoteWithoutRemote(t *testing.T) { + dir := makeRepo(t, "") + runGitIn(t, dir, "config", "--global", "--add", GitCKTrustedRemotes, "**") + + // A repository without a remote is never trusted. + uncached, cached := isRepoTrusted(t, dir) + assert.False(t, uncached) + assert.False(t, cached) +} + +func TestRepoTrustedByRemoteDeniedByUser(t *testing.T) { + dir := makeRepo(t, "https://github.com/my-org/my-repo.git") + makeTrustMarker(t, dir) + runGitIn(t, dir, "config", "--global", "--add", + GitCKTrustedRemotes, "https://github.com/my-org/**") + runGitIn(t, dir, "config", GitCKTrustAll, "false") + + // The explicit trust setting of the user wins. + uncached, cached := isRepoTrusted(t, dir) + assert.False(t, uncached) + assert.False(t, cached) +} + +func TestRepoTrustedByTrustMarkerOnly(t *testing.T) { + dir := makeRepo(t, "https://github.com/other-org/my-repo.git") + makeTrustMarker(t, dir) + runGitIn(t, dir, "config", GitCKTrustAll, "true") + + uncached, cached := isRepoTrusted(t, dir) + assert.True(t, uncached) + assert.True(t, cached) +} + +func TestRepoTrustedByRemoteShowsNoPrompt(t *testing.T) { + dir := makeRepo(t, "https://github.com/my-org/my-repo.git") + makeTrustMarker(t, dir) + runGitIn(t, dir, "config", "--global", "--add", + GitCKTrustedRemotes, "https://github.com/my-org/**") + + isTrusted, hasTrustFile, trustAllSet := IsRepoTrusted(git.NewCtxAt(dir), dir) + assert.True(t, isTrusted) + assert.True(t, hasTrustFile) + assert.False(t, trustAllSet) + + // This is the condition the runner uses to show the trust prompt. + assert.False(t, !isTrusted && hasTrustFile && !trustAllSet) +}