Pre-receive hooks: validando pushes no lado do servidor

1. Introdução aos hooks server-side no Git

Hooks do Git são scripts executados automaticamente em pontos específicos do ciclo de vida de um repositório. Eles permitem que desenvolvedores e administradores implementem lógica personalizada para validar, modificar ou reagir a operações do Git. Diferentemente dos hooks client-side (como pre-commit e commit-msg), que rodam na máquina de cada desenvolvedor e podem ser ignorados ou desabilitados, os hooks server-side executam no repositório central, garantindo que as regras sejam aplicadas a todos os pushes.

Os principais hooks server-side são:
- pre-receive: executado antes de qualquer referência ser atualizada, recebendo todas as referências do push via stdin
- update: executado para cada referência individualmente, permitindo validação por branch
- post-receive: executado após todas as referências serem atualizadas, ideal para notificações e builds

O hook pre-receive é particularmente poderoso porque permite rejeitar um push inteiro antes que qualquer alteração seja registrada no repositório, funcionando como uma barreira centralizada de validação.

2. Anatomia de um pre-receive hook

O script pre-receive deve estar localizado em hooks/pre-receive dentro do diretório do repositório bare no servidor. Ele recebe via stdin linhas no formato:

<old-value> <new-value> <ref-name>

Onde:
- old-value é o SHA-1 do commit atual da referência (40 zeros se a referência será criada)
- new-value é o SHA-1 do novo commit (40 zeros se a referência será deletada)
- ref-name é o nome completo da referência (ex: refs/heads/main)

O código de saída determina o resultado:
- 0 (zero): aceita o push
- Qualquer valor não-zero: rejeita o push, e a saída padrão do script é exibida ao usuário

Exemplo mínimo em shell:

#!/bin/sh
while read oldrev newrev refname
do
    echo "Push rejeitado: operações manuais não permitidas neste servidor."
    exit 1
done
exit 0

3. Validando mensagens de commit e autorias

Para acessar os commits de um push, usamos git rev-list para listar os commits entre oldrev e newrev, e git cat-file ou git log para inspecionar cada commit.

Exemplo de script que exige prefixo obrigatório na mensagem de commit:

#!/bin/sh
while read oldrev newrev refname
do
    commits=$(git rev-list $oldrev..$newrev 2>/dev/null)
    for commit in $commits
    do
        message=$(git cat-file -p $commit | sed -n '4,/^$/p' | head -n -1)
        if ! echo "$message" | grep -qE '^(feat|fix|chore|docs|refactor):'
        then
            echo "ERRO: Commit $commit não segue o padrão de mensagem."
            echo "Use: feat:, fix:, chore:, docs: ou refactor:"
            exit 1
        fi
    done
done
exit 0

Para verificar autor contra domínios permitidos:

#!/bin/sh
DOMINIOS_PERMITIDOS="empresa.com"
while read oldrev newrev refname
do
    commits=$(git rev-list $oldrev..$newrev 2>/dev/null)
    for commit in $commits
    do
        email=$(git log --format='%ae' -1 $commit)
        dominio=$(echo $email | cut -d'@' -f2)
        if ! echo "$DOMINIOS_PERMITIDOS" | grep -q "$dominio"
        then
            echo "ERRO: Commit $commit de $email não autorizado."
            exit 1
        fi
    done
done
exit 0

4. Controle de branches e permissões por usuário

Para identificar o usuário que está fazendo o push, pode-se usar variáveis de ambiente como GL_USER (GitLab) ou extrair do SSH. O script pode então aplicar regras específicas por branch.

Exemplo que protege a branch main e permite pushes para release/* apenas para QA:

#!/bin/sh
USUARIO=${GL_USER:-$(whoami)}
while read oldrev newrev refname
do
    case "$refname" in
        refs/heads/main)
            echo "ERRO: Push para main bloqueado. Use Pull Request."
            exit 1
            ;;
        refs/heads/release/*)
            if [ "$USUARIO" != "qa-team" ] && [ "$USUARIO" != "jenkins" ]
            then
                echo "ERRO: Apenas QA pode fazer push para release/*"
                exit 1
            fi
            # Verifica se é fast-forward
            if ! git merge-base --is-ancestor $oldrev $newrev
            then
                echo "ERRO: Apenas fast-forward permitido em release/*"
                exit 1
            fi
            ;;
    esac
done
exit 0

5. Validação de conteúdo e tamanho dos pushes

Para verificar arquivos específicos ou tamanho total, podemos inspecionar as árvores dos commits e usar git ls-tree e git cat-file para analisar objetos.

Exemplo que rejeita arquivos maiores que 10MB e extensões .exe:

#!/bin/sh
TAMANHO_MAXIMO=$((10 * 1024 * 1024))
EXTENSOES_BLOQUEADAS="\.exe$|\.dll$|\.pdb$"

while read oldrev newrev refname
do
    commits=$(git rev-list $oldrev..$newrev 2>/dev/null)
    for commit in $commits
    do
        tree=$(git rev-parse $commit^{tree})
        git ls-tree -r -l $tree | while read mode type obj size path
        do
            if [ "$type" = "blob" ] && [ "$size" -gt "$TAMANHO_MAXIMO" ]
            then
                echo "ERRO: Arquivo $path tem $size bytes (limite: $TAMANHO_MAXIMO)"
                exit 1
            fi
            if echo "$path" | grep -qE "$EXTENSOES_BLOQUEADAS"
            then
                echo "ERRO: Arquivo $path não é permitido."
                exit 1
            fi
        done
    done
done
exit 0

Para bloquear secrets, adicione verificações com regex para padrões como chaves AWS (AKIA[0-9A-Z]{16}) ou senhas.

6. Boas práticas e considerações de segurança

Para hooks complexos, considere usar linguagens mais robustas como Python ou Ruby:

#!/usr/bin/env python3
import sys
import subprocess

def get_commits(oldrev, newrev):
    result = subprocess.run(
        ['git', 'rev-list', f'{oldrev}..{newrev}'],
        capture_output=True, text=True
    )
    return result.stdout.strip().split('\n') if result.stdout else []

for line in sys.stdin:
    oldrev, newrev, refname = line.strip().split()
    commits = get_commits(oldrev, newrev)
    # Lógica de validação aqui
    sys.exit(0)

Práticas recomendadas:
- Logging: registre todas as rejeições em /var/log/git-hooks.log para auditoria
- Performance: evite git log com formatação complexa para pushes com centenas de commits
- Testes: crie um repositório de staging e simule pushes com git push --dry-run ou scripts de teste
- Atomicidade: ao atualizar hooks, copie o novo script para um arquivo temporário e depois renomeie

7. Integração com ferramentas de code review e CI/CD

Hooks podem consultar APIs externas para validar se um push pode ser aceito. Exemplo que verifica status de CI via API (GitLab):

#!/bin/sh
API_URL="https://gitlab.com/api/v4/projects/123/statuses"
TOKEN="seu-token"

while read oldrev newrev refname
do
    # Pega o último commit do push
    last_commit=$(git rev-list $oldrev..$newrev | tail -1)
    status=$(curl -s -H "PRIVATE-TOKEN: $TOKEN" \
        "$API_URL?ref=$refname&sha=$last_commit" | \
        python3 -c "import sys,json; print(json.load(sys.stdin)[0]['status'])")

    if [ "$status" != "success" ]
    then
        echo "ERRO: CI não passou para o commit $last_commit (status: $status)"
        exit 1
    fi
done
exit 0

Para integrar com Pull Requests, use a API do GitHub/GitLab para verificar se o branch tem aprovações pendentes.

8. Depuração e manutenção de hooks em produção

Para testar hooks sem impacto:

  1. Crie um repositório bare de staging: git init --bare /tmp/test-hooks.git
  2. Copie o hook para lá: cp hooks/pre-receive /tmp/test-hooks.git/hooks/
  3. Simule um push local: git push /tmp/test-hooks.git main

Para logging em produção:

#!/bin/sh
LOG_FILE="/var/log/git-hooks.log"
exec 2>>"$LOG_FILE"

while read oldrev newrev refname
do
    user=${GL_USER:-$(whoami)}
    echo "$(date): $user tentou push em $refname" >> "$LOG_FILE"
    # validação...
    if [ $? -ne 0 ]
    then
        echo "$(date): Push REJEITADO de $user em $refname" >> "$LOG_FILE"
        exit 1
    fi
done
exit 0

Para atualização atômica:

# Instalação segura
cp novo-hook /tmp/pre-receive.new
mv /tmp/pre-receive.new /caminho/repo.git/hooks/pre-receive

Referências