A maioria dos ficheiros de workflow referencia actions como actions/checkout@v4. Parece uma versão, mas é uma tag git, e quem controla o repositório pode apontar uma tag para qualquer commit que quiser, incluindo um que não existia ontem. O seu pipeline corre então o código novo com os seus secrets, no push seguinte, sem uma única linha alterada no seu repositório.
A solução não é glamorosa e não precisa de uma ferramenta nova. É uma tarde de pinning, um pequeno ficheiro de configuração e um par de linhas de permissões. Esta é a ordem pela qual o faríamos.
Porque é que as tags são o ponto fraco
Uma referência por tag é resolvida em tempo de execução. Se um atacante obtiver acesso de escrita ao repositório de uma action, ou à conta de um maintainer, pode fazer force-push de uma tag para um commit malicioso e todos os workflows que usam essa tag passam a usá-lo. Foi exatamente o que aconteceu no compromisso do tj-actions/changed-files em março de 2025, em que versões com novas tags despejaram segredos de CI nos logs de build.
Um SHA de commit completo, com 40 caracteres, é diferente. Identifica uma árvore exata de ficheiros. Ninguém o consegue fazer apontar para outro sítio. SHAs curtos e nomes de branches não contam: só o hash completo é imutável.
Faça pin de cada action de terceiros
Primeiro descubra o que está a usar. Isto lista todas as linhas uses: que ainda não são um SHA completo, sem contar as actions locais:
grep -rn "uses:" .github/workflows \
| grep -v "./" \
| grep -Ev "@[0-9a-f]{40}"
Pode resolver uma tag à mão com git ls-remote, mas uma ferramenta é mais rápida e menos sujeita a erros. O pinact, open-source, reescreve as tags para SHAs e mantém a versão como comentário no fim, para que as pessoas ainda a consigam ler:
pinact run
# before
- uses: actions/setup-node@v4
# after
- uses: actions/setup-node@<full-40-char-sha> # v4.1.0
Dois casos limite. Os steps baseados em Docker que usam docker:// devem ter pin por image digest, não por tag. E os reusable workflows chamados a partir de outros repositórios precisam do mesmo tratamento que as actions.
Deixe um bot manter os pins atualizados
A objeção habitual ao pinning é que deixa de receber correções. Não deixa, se o Dependabot vigiar o ecossistema github-actions. Ele percebe pins por SHA com comentários de versão e abre um pull request que atualiza ambos.
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
Agrupar mantém tudo num pull request por semana em vez de uma dúzia. Reveja-o como qualquer outra alteração de dependências: leia o diff da action quando o salto é grande ou o publisher é pequeno.
Reduzir o GITHUB_TOKEN
O pinning limita o código que corre. As permissões limitam o que esse código pode fazer se algo ainda correr mal. Defina o valor por omissão da organização ou do repositório como só de leitura, depois declare as permissões no topo de cada workflow e alargue-as apenas no job que precisa:
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
id-token: write
Um permissions: {} vazio também é válido, para jobs que só fazem lint ou testes. Já que está aqui, procure chaves de cloud de longa duração nos secrets e substitua-as por federação OIDC onde o seu fornecedor o suporte.
A armadilha do pull_request_target
pull_request_target corre no contexto do repositório base, com acesso a segredos e a um token que pode escrever. Existe para que os workflows possam pôr labels ou comentar em pull requests vindos de forks. A armadilha é fazer checkout do código do fork e corrê-lo:
on: pull_request_target
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha>
with:
ref: ${{ github.event.pull_request.head.sha }}
- run: npm install && npm test # runs attacker code with your secrets
O mesmo vale para interpolar campos não confiáveis, como o título de um pull request, diretamente num script run:. Passe-os por uma variável de ambiente, para que a shell os trate como dados.
Verifique o seu em 10 minutos
- Corra o grep acima e conte as referências sem pin.
- Corra
zizmor .github/workflows. Sinaliza actions sem pin, permissões excessivas, triggers arriscados e template injection. - Corra
actionlintpara apanhar os erros de sintaxe que o pinning por vezes introduz. - Verifique a definição do repositório para as permissões por omissão dos workflows e defina-a como read.
- Adicione o ficheiro do Dependabot e faça merge da primeira atualização agrupada.
Fica feito numa tarde. A partir daí, cada alteração ao que corre no seu pipeline chega como um pull request revisto, e não como um retag silencioso.
Onde isto fica na linha
Na torre (em desenvolvimento):