GitLab 使用 Server Hooks 校验 Commit 用户名与邮箱

March 2, 2021 默认分类

校验原因

在企业内部进行代码提交时, commit 中会存在提交者的 username 与 email

但该 username 与 email 是提交者在 Git 客户端自己设置的

如果提交者忘记设置或者设置错误, 并将 commit push 到远程服务后

当协作者需要寻找该 commit 提交者时, 错误的 username 与 email 会对协作者造成障碍

为解决这个问题, 需要在 GitLab 使用 Server Hooks 对 commit 进行校验, 只有 username 与 email 与 GitLab 中的一致才允许 push, 否则拒绝 push 并提示修改

相关文章资料

最开始用 Google 搜索到的方案是使用 GitLab 的 Push Rules 功能,具体文档见 这里,看完了我才发现这是企业版独有的,作为比较有逼格(qiong)的我们是不可能接受这种 “没技术含量” 的方式的;后来找了好多资料,发现还得借助 Git Hook 功能,文档见 Custom Git Hooks;简单地说 Git Hook 就是在 git 操作的不同阶段执行的预定义脚本,GitLab 目前支持 pre-receive update post-receive 这几个钩子,当然他可以链式调用;所以一切操作就得从这里入手

准备工作

如需对 commit 的 username 与 email 进行校验, 那么需要在校验脚本中获取 push 者的 username 与 email

通过 GitLab Server Hooks 文档可知存在 GL_USERNAME 环境变量, 该变量的值为 push 者的 GitLab 的 username, 但是缺乏 email 相关环境变量

为获取 push 者的 email, 需使用 GitLab 提供的 Users API 进行获取

通过 API 文档可知只有 admin 用户才返回用户 email, 所以需要先使用 admin 账号生成一个 TOKEN

这个 TOKEN 只是用来获取获取用户 email, 故创建时选择 read_user 的范围即可

校验用户名与邮箱 pre-receive 实现

GitHub 的 platform-samples 项目提供了一个 commit-current-user-check.sh 的 hook, 我们可以参考该脚本. 查阅gitlab了相关资料得出,在进行 push 时,GitLab 会调用这个钩子文件,
单独项目配置:
这个钩子文件必须放在 /var/opt/gitlab/git-data/repositories/<group>/<project>.git/custom_hooks 目录中,当然具体路径也可能是 /home/git/repositories/<group>/<project>.git/custom_hookscustom_hooks 目录需要自己创建,具体可以参阅文档的 Setup
全局项目配置: (参考)

对于从源安装的默认目录通常是/home/git/gitlab-shell/hooks。在此位置创建一个新目录。
对于Omnibus GitLab(docker 部署的gitlab也是在此路径),通常是安装/opt/gitlab/embedded/service/gitlab-shell/hooks

取决于钩的类型,它可以是一个 pre-receive.d, post-receive.dupdate.d目录, hooks有可能需要自己创建. 具体路径示例/opt/gitlab/embedded/service/gitlab-shell/hooks/pre-receive.d/(本人基于docker跑的gitlab....懒)

在进行 push 操作时,GitLab 会调用这个钩子文件,并且从 stdin 输入三个参数,分别为 之前的版本 commit ID、push 的版本 commit ID 和 push 的分支;根据 commit ID 我们就可以很轻松的获取到提交信息,从而实现进一步检测动作;根据 GitLab 的文档说明,当这个 hook 执行后以非 0 状态退出则认为执行失败,从而拒绝 push;同时会将 stderr 信息返回给 client 端;说了这么多,下面就可以直接上代码了,为了方便我就直接用 go 造了一个 pre-receive,官方文档说明了不限制语言. 完整代码

package main

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "log"
    "log/syslog"
    "net/http"
    "os"
    "os/exec"
    "regexp"
    "strconv"
    "strings"
)

// 请求gitlab api返回用户信息json结构体
type Commits struct {
    Commit               string   `json:"commit,omitempty"`
    AbbreviatedCommit    string   `json:"abbreviated_commit,omitempty"`
    Subject              string `json:"subject,omitempty"`
    SanitizedSubjectLine string   `json:"sanitized_subject_line,omitempty"`
    Author               User     `json:"author,omitempty"`
    Committer            User     `json:"committer,omitempty"`
}

type User struct {
    Name         string `json:"name,omitempty"`
    Email        string `json:"email,omitempty"`
    Date         string `json:"date,omitempty"`
    Timestamp    string `json:"timestamp,omitempty"`
    RelativeDate string `json:"relative_date,omitempty"`
}

type GitlabUserInfo struct {
    ID                             int           `json:"id,omitempty"`
    Name                           string        `json:"name,omitempty"`
    Username                       string        `json:"username,omitempty"`
    State                          string        `json:"state,omitempty"`
    AvatarURL                      string        `json:"avatar_url,omitempty"`
    WebURL                         string        `json:"web_url,omitempty"`
    CreatedAt                      string        `json:"created_at,omitempty"`
    Bio                            string        `json:"bio,omitempty"`
    BioHTML                        string        `json:"bio_html,omitempty"`
    Location                       interface{}   `json:"location,omitempty"`
    PublicEmail                    string        `json:"public_email,omitempty"`
    Skype                          string        `json:"skype,omitempty"`
    Linkedin                       string        `json:"linkedin,omitempty"`
    Twitter                        string        `json:"twitter,omitempty"`
    WebsiteURL                     string        `json:"website_url,omitempty"`
    Organization                   interface{}   `json:"organization,omitempty"`
    JobTitle                       string        `json:"job_title,omitempty"`
    WorkInformation                interface{}   `json:"work_information,omitempty"`
    LastSignInAt                   string        `json:"last_sign_in_at,omitempty"`
    ConfirmedAt                    string        `json:"confirmed_at,omitempty"`
    LastActivityOn                 string        `json:"last_activity_on,omitempty"`
    Email                          string        `json:"email,omitempty"`
    ThemeID                        int           `json:"theme_id,omitempty"`
    ColorSchemeID                  int           `json:"color_scheme_id,omitempty"`
    ProjectsLimit                  int           `json:"projects_limit,omitempty"`
    CurrentSignInAt                string        `json:"current_sign_in_at,omitempty"`
    Identities                     []interface{} `json:"identities,omitempty"`
    CanCreateGroup                 bool          `json:"can_create_group,omitempty"`
    CanCreateProject               bool          `json:"can_create_project,omitempty"`
    TwoFactorEnabled               bool          `json:"two_factor_enabled,omitempty"`
    External                       bool          `json:"external,omitempty"`
    PrivateProfile                 bool          `json:"private_profile,omitempty"`
    SharedRunnersMinutesLimit      interface{}   `json:"shared_runners_minutes_limit,omitempty"`
    ExtraSharedRunnersMinutesLimit interface{}   `json:"extra_shared_runners_minutes_limit,omitempty"`
    IsAdmin                        bool          `json:"is_admin,omitempty"`
    Note                           interface{}   `json:"note,omitempty"`
    UsingLicenseSeat               bool          `json:"using_license_seat,omitempty"`
}

// 校验失败返回给用户的错误提示
const checkFailedUser = `##############################################
##
## 提交用户名与gitlab登录用户名不匹配, 请修改!
##   git config --global user.name "%s"
##
## 修改后, 先操作撤销commit, 以下示例只是撤销一个
## git reset --soft HEAD~1
##
##############################################`

const checkFailedEmail = `##############################################
##
## 提交邮箱与gitlab登录邮箱不匹配, 请修改!
##   git config --global user.email "%s"
##
## 修改后, 先操作撤销commit, 以下示例只是撤销一个
## git reset --soft HEAD~1
##
##############################################`

const checkFailedMsgLen = `##############################################
## 先撤销commit, 然后再进行commit提交。以下示例只是撤销一个
## git reset --soft HEAD~1
##
##############################################`

const (
    // 是否开启严格模式,严格模式下将校验所有的提交信息格式(多 commit 下)
    strictMode = true
    // commit log format
    CommitLogFormat = `{"commit":"%H","sanitized_subject_line":"%f","author":{"name":"%an","email":"%ae","timestamp":"%at"},"committer":{"name":"%cn","email":"%ce","timestamp":"%ct"}}`

    // zero_commit
    ZeroCommit = `0000000000000000000000000000000000000000`
)

// Gitlab API
const (
    GITLAB_URL = "http://127.0.0.1"
    GITLAB_TOKEN = "xxxxxxxxx"
)

var logger *log.Logger

// 日志打印到syslog
func init() {
    sysLog, err := syslog.Dial("", "", syslog.LOG_ERR, "Saturday")
    if err != nil {
        log.Fatal(err)
    }
    logger = log.New(sysLog, "", 0777)
}

func getGitlabUserInfo(userName string) GitlabUserInfo {
    req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v4/users?username=%s", GITLAB_URL, userName), nil)
    if err != nil {
        logger.Println(err)
        _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 9-1, 未知错误, 请联系管理员")
        os.Exit(9)
    }
    req.Header.Set("Private-Token", GITLAB_TOKEN)
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        logger.Println(err)
        _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 9-2, 未知错误, 请联系管理员")
        os.Exit(9)
    }
    defer resp.Body.Close()
    bodyByte, err := ioutil.ReadAll(resp.Body)
    if err != nil {
        logger.Println(err)
        _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 9-3, 未知错误, 请联系管理员")
        os.Exit(9)
    }
    logger.Println(string(bodyByte))
    var gitlabUserInfo []GitlabUserInfo
    if err := json.Unmarshal(bodyByte, &gitlabUserInfo); err != nil {
        logger.Println(err)
        _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 9-4, 未知错误, 请联系管理员")
        os.Exit(9)
    }
    if len(gitlabUserInfo) == 0 {
        _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf("GL-HOOK-ERR: exit code 9-5, 未找到%s用户", userName))
        os.Exit(9)
    }
    return gitlabUserInfo[0]
}

func getCommitRevList(commit string) ([]string, error) {
    getCommitMsgCmd := exec.Command("git", "rev-list", commit, "--not", "--all")
    getCommitMsgCmd.Stdin = os.Stdin
    getCommitMsgCmd.Stderr = os.Stderr
    b, err := getCommitMsgCmd.Output()
    if err != nil {
        return nil, err
    }
    commitList := strings.Split(string(b), "\n")
    var res []string
    for _, commits := range commitList {
        if strings.ReplaceAll(commits, " ", "") == "" {
            continue
        }
        res = append(res, commits)
    }
    return res, nil
}

func getCommitInfo(commit string) ([]byte, error) {
    getCommitMsgCmd := exec.Command("git", "log", "-n", "1", commit, fmt.Sprintf(`--pretty=format:%s`, CommitLogFormat))
    getCommitMsgCmd.Stdin = os.Stdin
    getCommitMsgCmd.Stderr = os.Stderr
    res, err := getCommitMsgCmd.Output()
    if err != nil {
        return nil, err
    }
    return res, nil
}

func getCommitMsg(commit string) (string, error) {
    getCommitMsgCmd := exec.Command("git", "log", "-n", "1", commit, "--pretty=format:%s")
    getCommitMsgCmd.Stdin = os.Stdin
    getCommitMsgCmd.Stderr = os.Stderr
    byteMsg, err := getCommitMsgCmd.Output()
    if err != nil {
        return "", err
    }
    res := strings.ReplaceAll(string(byteMsg), " ", "")
    res = strings.ReplaceAll(string(byteMsg), " ", "")
    res = strings.ReplaceAll(string(byteMsg), "\n", "")
    return res, nil
}

func main() {
    input, _ := ioutil.ReadAll(os.Stdin)
    param := strings.Fields(string(input))
    // allow branch/tag delete
    if param[1] == ZeroCommit {
        os.Exit(0)
    }

    // Check for new branch or tag
    var span string
    if param[0] == ZeroCommit {
        span = param[1]
    } else {
        span = fmt.Sprintf("%s...%s", param[0], param[1])
    }
    commitRevList, err := getCommitRevList(span)
    if err != nil || len(commitRevList) == 0 {
        logger.Println(err)
        os.Exit(0)
    }

    userName := os.Getenv("GL_USERNAME")
    projectID := strings.Split(os.Getenv("GL_REPOSITORY"), "-")[1]

    // get user email for gitlab api
    userInfo := getGitlabUserInfo(userName)

    for _, commit := range commitRevList {
        commitInfo, err := getCommitInfo(commit)
        if err != nil || len(commitInfo) == 0 {
            logger.Println(err)
            _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 444, can't get commit user info", commit, err)
            os.Exit(444)
        }
        logger.Println(string(commitInfo))
        var commits = Commits{}
        if err := json.Unmarshal(commitInfo, &commits); err != nil {
            logger.Println(err)
            logger.Printf("error decoding sakura response: %v", err)
            if e, ok := err.(*json.SyntaxError); ok {
                logger.Printf("syntax error at byte offset %d", e.Offset)
            }
            logger.Printf("sakura response: %q", commitInfo)
            logger.Printf("sakura response: %q", commitInfo)
            _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 555 未知错误, 请联系管理员")
            os.Exit(555)
        }

        // continue old commit
        _t, err := strconv.Atoi(commits.Committer.Timestamp)
        if err != nil {
            logger.Println(err)
            _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: exit code 777, 知错误, 请联系管理员")
            os.Exit(777)
        }
        if _t < 1607936839 {
            continue
        }
        logger.Println(userName, projectID, commits.Commit, commits.Author.Name, userName)
        // check auth user name
        //if commits.Author.Name != userName && commits.Author.Name != userInfo.Name {
        //  _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: 用户名不对: ", commits.Author.Name, ", 应为: ", userName)
        //  _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(checkFailedUser, userName))
        //  os.Exit(999)
        //}
        logger.Println(userName, projectID, commits.Commit, commits.Author.Email, userInfo.Email)
        //check auth user email
        //if commits.Author.Email != userInfo.Email {
        //  _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: 邮箱不对: ", commits.Author.Email, ", 应为: ", userInfo.Email)
        //  _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(checkFailedEmail, userInfo.Email))
        //  os.Exit(999)
        //}
        logger.Println(userName, projectID, commits.Commit, commits.Committer.Name, userName)
        // check committer user name
        if commits.Committer.Name != userName && commits.Committer.Name != userInfo.Name {
            _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: 用户名不对: ", commits.Committer.Name, ", 应为: ", userName)
            _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(checkFailedUser, userName))
            os.Exit(999)
        }
        logger.Println(userName, projectID, commits.Commit, commits.Committer.Email, userInfo.Email)
        // check committer user email
        if commits.Committer.Email != userInfo.Email {
            _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: 邮箱不对: ", commits.Committer.Email, ", 应为: ", userInfo.Email)
            _, _ = fmt.Fprintln(os.Stderr, fmt.Sprintf(checkFailedEmail, userInfo.Email))
            os.Exit(999)
        }

        commits.Subject, err = getCommitMsg(commits.Commit)
        if err != nil {
            logger.Println(err)
            logger.Println("con't get commit message")
            os.Exit(0)
        }

        // check message length
        if len(commits.Subject) < 8 {
            _, _ = fmt.Fprintln(os.Stderr, "GL-HOOK-ERR: Commit message太短, 没有任何意义")
            _, _ = fmt.Fprintln(os.Stderr, checkFailedMsgLen)
            os.Exit(666)
        }

        if !strictMode {
            os.Exit(0)
        }
    }
}

添加新评论