From 9c931cbaa6d41ef405ac58e32747a5fc1733ff9c Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 7 Sep 2026 22:19:51 +0000 Subject: [PATCH] feat: enforce signed commits on pre-push Add check-signed-commits pre-push hook in Go to verify that all commits being pushed are cryptographically signed. Also configure default_install_hook_types in .pre-commit-config.yaml to ensure pre-push and commit-msg hooks are installed by default. --- .pre-commit-config.yaml | 12 + hack/check_signed_commits/main.go | 330 +++++++++++++++++++++++++ hack/check_signed_commits/main_test.go | 241 ++++++++++++++++++ 3 files changed, 583 insertions(+) create mode 100644 hack/check_signed_commits/main.go create mode 100644 hack/check_signed_commits/main_test.go diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a7829892..d0a36e3e9 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,3 +1,8 @@ +default_install_hook_types: + - pre-commit + - pre-push + - commit-msg + repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v6.0.0 @@ -75,3 +80,10 @@ repos: types: [go] pass_filenames: false stages: [pre-push] + - id: check-signed-commits + name: check signed commits + entry: go run ./hack/check_signed_commits + language: system + pass_filenames: false + always_run: true + stages: [pre-push] diff --git a/hack/check_signed_commits/main.go b/hack/check_signed_commits/main.go new file mode 100644 index 000000000..3e951041f --- /dev/null +++ b/hack/check_signed_commits/main.go @@ -0,0 +1,330 @@ +package main + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +type commandRunner func(name string, args ...string) ([]byte, error) + +type runContext struct { + getenv func(string) string + stdin io.Reader + isStdinPipe bool + runner commandRunner +} + +type commitInfo struct { + sha string + shortHash string + subject string + author string +} + +func realRunner(name string, args ...string) ([]byte, error) { + cmd := exec.Command(name, args...) + return cmd.Output() +} + +func isAllZeros(s string) bool { + if s == "" { + return false + } + for _, c := range s { + if c != '0' { + return false + } + } + return true +} + +func splitLines(b []byte) []string { + var lines []string + scanner := bufio.NewScanner(bytes.NewReader(b)) + for scanner.Scan() { + text := strings.TrimSpace(scanner.Text()) + if text != "" { + lines = append(lines, text) + } + } + return lines +} + +func hasSignatureHeader(commitData []byte) bool { + header, _, _ := bytes.Cut(commitData, []byte("\n\n")) + for line := range bytes.SplitSeq(header, []byte("\n")) { + if bytes.HasPrefix(line, []byte("gpgsig ")) || + bytes.HasPrefix(line, []byte("gpgsig-sha256 ")) { + return true + } + } + return false +} + +func isCommitSigned(commit string, runner commandRunner) bool { + if _, err := runner("git", "verify-commit", commit); err == nil { + return true + } + out, err := runner("git", "cat-file", "commit", commit) + if err != nil { + return false + } + return hasSignatureHeader(out) +} + +func commitsFromNewBranch( + toRef, remoteName string, + runner commandRunner, +) ([]string, error) { + if remoteName == "" { + remoteName = "origin" + } + out, err := runner( + "git", + "rev-list", + toRef, + "--not", + "--remotes="+remoteName, + ) + if err == nil && len(bytes.TrimSpace(out)) > 0 { + return splitLines(out), nil + } + out, err = runner("git", "rev-list", toRef, "--not", "--remotes") + if err == nil && len(bytes.TrimSpace(out)) > 0 { + return splitLines(out), nil + } + out, err = runner("git", "rev-list", "-n", "1", toRef) + if err != nil { + return nil, err + } + return splitLines(out), nil +} + +func collectCommitsFromEnv( + getenv func(string) string, + runner commandRunner, +) ([]string, bool, error) { + toRef := getenv("PRE_COMMIT_TO_REF") + if toRef == "" { + return nil, false, nil + } + + // Remote branch deletion + if isAllZeros(toRef) { + return nil, true, nil + } + + fromRef := getenv("PRE_COMMIT_FROM_REF") + if fromRef != "" && !isAllZeros(fromRef) { + out, err := runner("git", "rev-list", fromRef+".."+toRef) + if err != nil { + return nil, true, err + } + return splitLines(out), true, nil + } + + commits, err := commitsFromNewBranch( + toRef, + getenv("PRE_COMMIT_REMOTE_NAME"), + runner, + ) + return commits, true, err +} + +func commitsForSHA( + localSHA, remoteSHA string, + runner commandRunner, +) ([]string, error) { + if isAllZeros(remoteSHA) || remoteSHA == "" { + out, err := runner("git", "rev-list", localSHA, "--not", "--remotes") + if err == nil && len(bytes.TrimSpace(out)) > 0 { + return splitLines(out), nil + } + out, err = runner("git", "rev-list", "-n", "1", localSHA) + if err != nil { + return nil, err + } + return splitLines(out), nil + } + + out, err := runner("git", "rev-list", remoteSHA+".."+localSHA) + if err != nil { + return nil, err + } + return splitLines(out), nil +} + +func parseStdinLine(line string, runner commandRunner) ([]string, error) { + fields := strings.Fields(line) + if len(fields) < 4 || isAllZeros(fields[1]) { + return nil, nil + } + return commitsForSHA(fields[1], fields[3], runner) +} + +func collectCommitsFromStdin( + r io.Reader, + runner commandRunner, +) ([]string, bool, error) { + var commits []string + scanner := bufio.NewScanner(r) + hasInput := false + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + hasInput = true + c, err := parseStdinLine(line, runner) + if err != nil { + return nil, true, err + } + commits = append(commits, c...) + } + + return commits, hasInput, scanner.Err() +} + +func fallbackCommits(runner commandRunner) ([]string, error) { + out, err := runner("git", "rev-list", "HEAD", "--not", "--remotes") + if err == nil && len(bytes.TrimSpace(out)) > 0 { + return splitLines(out), nil + } + out, err = runner("git", "rev-list", "-n", "1", "HEAD") + if err != nil { + return nil, err + } + return splitLines(out), nil +} + +func collectCommits(ctx runContext) ([]string, error) { + commits, handled, err := collectCommitsFromEnv(ctx.getenv, ctx.runner) + if handled { + return commits, err + } + + if ctx.isStdinPipe { + stdinCommits, hasInput, scanErr := collectCommitsFromStdin( + ctx.stdin, + ctx.runner, + ) + if scanErr != nil { + return nil, scanErr + } + if hasInput { + return stdinCommits, nil + } + } + + return fallbackCommits(ctx.runner) +} + +func getCommitInfo(commit string, runner commandRunner) commitInfo { + info := commitInfo{sha: commit, shortHash: commit} + out, err := runner( + "git", + "log", + "-1", + "--format=%h%x00%s%x00%an <%ae>", + commit, + ) + if err == nil { + parts := strings.Split(string(bytes.TrimSpace(out)), "\x00") + if len(parts) >= 3 { + info.shortHash = parts[0] + info.subject = parts[1] + info.author = parts[2] + } + } + return info +} + +func findUnsignedCommits( + commits []string, + runner commandRunner, +) []commitInfo { + seen := make(map[string]bool, len(commits)) + var unsigned []commitInfo + + for _, commit := range commits { + if seen[commit] { + continue + } + seen[commit] = true + + if !isCommitSigned(commit, runner) { + unsigned = append(unsigned, getCommitInfo(commit, runner)) + } + } + return unsigned +} + +func formatUnsignedError(unsigned []commitInfo) string { + var b strings.Builder + b.WriteString( + "ERROR: Unsigned commit(s) detected. All commits pushed to the repository must be cryptographically signed.\n\n", + ) + b.WriteString("Unsigned commits:\n") + for _, info := range unsigned { + fmt.Fprintf( + &b, + " - %s: %s (%s)\n", + info.shortHash, + info.subject, + info.author, + ) + } + b.WriteString("\nTo sign your commits before pushing:\n") + b.WriteString(" - Configure automatic signing in git:\n") + b.WriteString(" git config commit.gpgsign true\n") + b.WriteString(" - To sign your latest commit:\n") + b.WriteString(" git commit --amend --no-edit -S\n") + b.WriteString(" - To sign multiple previous commits:\n") + b.WriteString( + " git rebase --exec 'git commit --amend --no-edit -S' ", + ) + return b.String() +} + +func run(ctx runContext) error { + commits, err := collectCommits(ctx) + if err != nil { + return fmt.Errorf("determine commits to check: %w", err) + } + + if len(commits) == 0 { + return nil + } + + unsigned := findUnsignedCommits(commits, ctx.runner) + if len(unsigned) > 0 { + return errors.New(formatUnsignedError(unsigned)) + } + + fmt.Println("All commits are signed.") + return nil +} + +func main() { + stat, err := os.Stdin.Stat() + isPipe := err == nil && (stat.Mode()&os.ModeCharDevice) == 0 + + ctx := runContext{ + getenv: os.Getenv, + stdin: os.Stdin, + isStdinPipe: isPipe, + runner: realRunner, + } + + if err := run(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/hack/check_signed_commits/main_test.go b/hack/check_signed_commits/main_test.go new file mode 100644 index 000000000..07d47e46f --- /dev/null +++ b/hack/check_signed_commits/main_test.go @@ -0,0 +1,241 @@ +package main + +import ( + "errors" + "strings" + "testing" +) + +const ( + cmdGit = "git" + cmdRevList = "rev-list" + cmdCatFile = "cat-file" + cmdVerifyCommit = "verify-commit" + envToRef = "PRE_COMMIT_TO_REF" + envFromRef = "PRE_COMMIT_FROM_REF" + zeroHash = "0000000000000000000000000000000000000000" + dummySignature = "tree 123\ngpgsig -----BEGIN SSH SIGNATURE-----\n\nmsg" +) + +func TestIsAllZeros(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"", false}, + {"0", true}, + {zeroHash, true}, + {"0000000000000000000000000000000000000001", false}, + {"a000", false}, + } + + for _, tt := range tests { + if got := isAllZeros(tt.input); got != tt.want { + t.Errorf("isAllZeros(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestHasSignatureHeaderPGPAndSSH(t *testing.T) { + pgpCommit := []byte( + "tree 1\nauthor T\ngpgsig -----BEGIN PGP SIGNATURE-----\n abc\n -----END PGP SIGNATURE-----\n\nmsg", + ) + sshCommit := []byte( + "tree 2\nauthor T\ngpgsig -----BEGIN SSH SIGNATURE-----\n abc\n -----END SSH SIGNATURE-----\n\nmsg", + ) + + if !hasSignatureHeader(pgpCommit) { + t.Errorf("expected pgpCommit to have signature header") + } + if !hasSignatureHeader(sshCommit) { + t.Errorf("expected sshCommit to have signature header") + } +} + +func TestHasSignatureHeaderOther(t *testing.T) { + sha256Commit := []byte( + "tree 3\ngpgsig-sha256 -----BEGIN PGP SIGNATURE-----\n abc\n\nmsg", + ) + unsignedCommit := []byte("tree 4\nauthor T\n\nmsg") + bodyOnlyGpgsig := []byte("tree 5\nauthor T\n\ngpgsig in commit body") + + if !hasSignatureHeader(sha256Commit) { + t.Errorf("expected sha256Commit to have signature header") + } + if hasSignatureHeader(unsignedCommit) { + t.Errorf("expected unsignedCommit to not have signature header") + } + if hasSignatureHeader(bodyOnlyGpgsig) { + t.Errorf("expected bodyOnlyGpgsig to not have signature header") + } +} + +func TestIsCommitSignedVerifySuccess(t *testing.T) { + runner := func(name string, args ...string) ([]byte, error) { + if name == cmdGit && len(args) >= 2 && args[0] == cmdVerifyCommit { + return []byte("Good signature"), nil + } + return nil, errors.New("unexpected command") + } + if !isCommitSigned("abc", runner) { + t.Errorf("expected commit to be signed when verify-commit succeeds") + } +} + +func TestIsCommitSignedCatFileFallback(t *testing.T) { + runner := func(name string, args ...string) ([]byte, error) { + if name == cmdGit && len(args) >= 2 && args[0] == cmdVerifyCommit { + return nil, errors.New("verification failed") + } + if name == cmdGit && len(args) >= 3 && args[0] == cmdCatFile { + return []byte(dummySignature), nil + } + return nil, errors.New("unexpected command") + } + if !isCommitSigned("abc", runner) { + t.Errorf("expected commit to be signed when cat-file has gpgsig") + } +} + +func TestIsCommitSignedUnsigned(t *testing.T) { + runner := func(name string, args ...string) ([]byte, error) { + if name == cmdGit && len(args) >= 2 && args[0] == cmdVerifyCommit { + return nil, errors.New("verification failed") + } + if name == cmdGit && len(args) >= 3 && args[0] == cmdCatFile { + return []byte("tree 123\nauthor Test\n\nmsg"), nil + } + return nil, errors.New("unexpected command") + } + if isCommitSigned("abc", runner) { + t.Errorf("expected commit to not be signed") + } +} + +func TestCollectCommitsFromEnvBranchDeletion(t *testing.T) { + env := map[string]string{envToRef: zeroHash} + commits, handled, err := collectCommitsFromEnv( + func(k string) string { return env[k] }, + nil, + ) + if err != nil || !handled || len(commits) != 0 { + t.Errorf( + "expected branch deletion handled with 0 commits, got handled=%v, commits=%v, err=%v", + handled, + commits, + err, + ) + } +} + +func TestCollectCommitsFromEnvExistingRange(t *testing.T) { + env := map[string]string{ + envFromRef: "base123", + envToRef: "head456", + } + runner := func(_ string, args ...string) ([]byte, error) { + if args[0] == cmdRevList && args[1] == "base123..head456" { + return []byte("commit1\ncommit2\n"), nil + } + return nil, errors.New("unexpected command") + } + commits, handled, err := collectCommitsFromEnv( + func(k string) string { return env[k] }, + runner, + ) + if err != nil || !handled || len(commits) != 2 { + t.Errorf( + "expected 2 commits, got handled=%v, len=%d, err=%v", + handled, + len(commits), + err, + ) + } +} + +func TestCollectCommitsFromStdin(t *testing.T) { + stdinData := `refs/heads/main 1111 refs/heads/main 2222 +refs/heads/del 0000000000000000000000000000000000000000 refs/heads/del 3333 +refs/heads/new 4444 refs/heads/new 0000000000000000000000000000000000000000 +` + runner := func(_ string, args ...string) ([]byte, error) { + if args[0] == cmdRevList && args[1] == "2222..1111" { + return []byte("c1\n"), nil + } + if args[0] == cmdRevList && args[1] == "4444" { + return []byte("c2\n"), nil + } + return nil, errors.New("unexpected command") + } + + commits, hasInput, err := collectCommitsFromStdin( + strings.NewReader(stdinData), + runner, + ) + if err != nil || !hasInput || len(commits) != 2 { + t.Fatalf( + "expected 2 commits from stdin, got %v, hasInput=%v, err=%v", + commits, + hasInput, + err, + ) + } +} + +func TestRunUnsigned(t *testing.T) { + env := map[string]string{ + envFromRef: "base", + envToRef: "head", + } + runner := func(_ string, args ...string) ([]byte, error) { + switch args[0] { + case cmdRevList: + return []byte("c_unsigned\n"), nil + case cmdVerifyCommit: + return nil, errors.New("unverified") + case cmdCatFile: + return []byte("tree 1\nauthor A\n\nmsg"), nil + case "log": + return []byte("c_unsigned\x00feat: test\x00Author "), nil + } + return nil, errors.New("unexpected") + } + + ctx := runContext{ + getenv: func(k string) string { return env[k] }, + runner: runner, + isStdinPipe: false, + } + err := run(ctx) + if err == nil { + t.Fatalf("expected error for unsigned commit") + } + if !strings.Contains(err.Error(), "Unsigned commit(s) detected") { + t.Errorf("expected error message, got: %v", err) + } +} + +func TestRunSigned(t *testing.T) { + env := map[string]string{ + envFromRef: "base", + envToRef: "head", + } + runner := func(_ string, args ...string) ([]byte, error) { + if args[0] == cmdRevList { + return []byte("c_signed\n"), nil + } + if args[0] == cmdVerifyCommit { + return []byte("Good signature"), nil + } + return nil, errors.New("unexpected") + } + + ctx := runContext{ + getenv: func(k string) string { return env[k] }, + runner: runner, + isStdinPipe: false, + } + if err := run(ctx); err != nil { + t.Fatalf("expected success for signed commit, got: %v", err) + } +}