Destino Dev

Blog

Validando assinaturas de imagens Docker no Kubernetes com cosign e OPA Gatekeeper

Por John Pires · 19 de março de 2026

Validando assinaturas de imagens Docker no Kubernetes com cosign e OPA Gatekeeper

Motivacao e trade-offs

Por que assinar imagens importa

A cadeia de suprimento de software (software supply chain) é um vetor crítico de ataques: imagens comprometidas podem chegar ao cluster através de pushs maliciosos, CI comprometido ou cadastros de terceiros. Assinar imagens garante autenticidade e integridade entre build e deploy. Verificar assinaturas antes do agendamento de pods reduz risco operacional e facilita auditoria.

Trade-offs práticos

Existem escolhas a fazer: chave gerenciada vs chaveless (Fulcio), verificação no CI vs verificação em runtime, e implementação de políticas via Gatekeeper vs webhooks dedicados. Cada opção tem custo/benefício:

  • Chave privada (armazenada em CI/CD): maior controle, necessidade de rotação e proteção.
  • Keyless (Fulcio): conveniência, menor responsabilidade pelo gerenciamento de chaves, depende de terceiros (trust).
  • Verificação no CI: impede push de imagens não assinadas, mas não protege contra imagens assinadas por actor malicioso.
  • Verificação no admission (Gatekeeper): garante que apenas imagens verificadas rodem no cluster — é aquela defesa em profundidade que recomendamos.
Tip: combine checks em CI (filtro precoce) com verificação em admission (controle definitivo). Assim você reduz latência de deploy e mantém segurança no cluster.

Fundamentos tecnicos

Como o cosign funciona (visão prática)

cosign assina artefatos OCI (imagens) e armazena assinaturas em registros ou em um repositório de confiança (Rekor). Assinaturas podem ser feitas com chave pública/privada ou keyless via Fulcio/Transparent Log. A verificação consiste em: recuperar assinatura do registro, validar cadeia de confiança e checar que o digest do artefato corresponde à assinatura.

Como o Gatekeeper/Opa funciona

OPA Gatekeeper é um Admission Controller que aplica políticas Rego declarativas. Permite criar ConstraintTemplates (com rego embutido) e Constraints que declaram regras específicas. Gatekeeper é altamente reutilizável para validação de recursos Kubernetes na etapa de admission.

Arquitetura recomendada

Arquitetura prática e implementável sem depender de execuções remotas do Rego:

  1. CI constrói e assina imagem com cosign (key/keyless) e publica assinatura no registro.
  2. Um componente verificador (CronJob/controller) em Kubernetes ou um processo externo verifica assinaturas e atualiza uma lista de digests aprovados em um Constraint Gatekeeper (ou em uma ConfigMap/CRD que você usa como fonte de verdade para as políticas).
  3. Gatekeeper valida pods usando essa lista: imagens devem usar digest (sha256:...) e estar na lista de aprovados.

Esse padrão separa responsabilidades de verificação (ferramenta que tem cosign disponível) e validação (Gatekeeper), permitindo políticas expressivas sem exigir que Rego execute verificações externas.

Exemplo 1: caso simples (assinatura com chave e validação via Gatekeeper)

Contexto e objetivo

Objetivo: forçar que apenas imagens com assinaturas verificadas sejam agendadas. Fluxo simples: CI assina imagem com cosign (chave RSA), um script verifica e atualiza um Constraint com digests aprovados, Gatekeeper bloqueia pods que referenciam imagens não aprovadas.

Assinar imagem com cosign (com chave)

O comando abaixo gera uma chave e assina uma imagem. Ele deve rodar na pipeline de CI antes do push ou logo após o push.

O que faz: gera par de chaves e cria assinatura da imagem referenciada por TAG. A assinatura é armazenada no registro (padrão cosign: o ramo "signature" do manifest).

# gera chave (local, guardada no CI secrets vault melhor que workspace do runner)
cosign generate-key-pair && ls -l cosign.key cosign.pub

# assina a imagem (tag existente):
cosign sign --key cosign.key registry.example.com/myapp:1.2.3

# verifica localmente:
cosign verify --key cosign.pub registry.example.com/myapp:1.2.3

O que acontece: após o sign, a imagem no registry tem associação de assinatura; verify retorna 0 se a assinatura bater com a chave pública.

ConstraintTemplate e Constraint Gatekeeper (Rego)

Contexto: vamos criar um ConstraintTemplate que lê uma lista de digests no campo "parameters.approvedDigests" do Constraint. Um processo externo vai atualizar esse campo. O Rego valida que todo container do pod usa digest e que o digest está na lista.

O que faz: define a política que será aplicada pelo Gatekeeper durante admission.

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8simageverified
spec:
  crd:
    spec:
      names:
        kind: K8sImageVerified
      validation:
        openAPIV3Schema:
          properties:
            approvedDigests:
              type: array
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8simageverified

        violation[{
          "msg": msg
        }] {
          input.review.kind.kind == "Pod"

          # pega todos os images dos containers
          some i
          image := input.review.object.spec.containers[i].image

          not image_is_allowed(image, input.parameters.approvedDigests)

          msg := sprintf("image %v is not an approved signed image", [image])
        }

        image_is_allowed(image, approved) {
          # força uso de digest: registry/path@sha256:...
          contains(image, "@sha256:")
          parts := split(image, "@")
          digest := parts[1]
          digest == _
          approved[_] == digest
        }

O que acontece: crie esse ConstraintTemplate no cluster; ele define a regra que depois será usada pelo Constraint.

Constraint exemplo

Contexto: Constraint com campo approvedDigests vazio inicialmente. Uma etapa de verificação vai preencher esse campo com os digests verificados.

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sImageVerified
metadata:
  name: approved-image-digests
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
  parameters:
    approvedDigests: []

O que acontece: o Constraint existe e bloqueia imagens que não usem digest (ou que usem digest não listado). Ainda não bloqueia nada até que approvedDigests seja preenchido.

Script simples para verificar e atualizar Constraint

Contexto: processo externo mínimo (pode ser job CI, pipeline ou CronJob) que usa cosign para verificar e atualiza o Constraint via kubectl patch. Esse script assume que cosign.verify pode retornar digest e que kubectl tem permissão para patch.

# script: verify-and-patch.sh
# entrada: imagem (registry/path:tag), key pública, nome do constraint
IMAGE="$1"   # ex: registry.example.com/myapp:1.2.3
PUBKEY="$2"  # arquivo cosign.pub
CONSTRAINT_NAME="$3" # approved-image-digests

# verifica e extrai digest
DIGEST=$(cosign verify --key "$PUBKEY" -json "$IMAGE" | jq -r '.[0].critical.identity.docker-reference // empty')
# Observação: dependendo da versão do cosign, ajuste a extração do digest. Aqui usamos um fallback simples:
if [ -z "$DIGEST" ]; then
  # fallback: resolve digest via skopeo inspect
  DIGEST=$(skopeo inspect --format '{{.Digest}}' docker://$IMAGE)
fi

# constrói payload para patch (adiciona digest ao array se não existir)
PATCH=$(cat <<EOF
{"spec": {"parameters": {"approvedDigests": ["$DIGEST"]}}}
EOF
)

kubectl patch K8sImageVerified $CONSTRAINT_NAME --type merge -p "$PATCH"

O que acontece: o script verifica a assinatura, resolve o digest e faz um patch no Constraint. Em produção, você vai querer lógica para adicionar/mesclar múltiplos digests e tratar concorrência.

Exemplo 2: caso avancado do mundo real (GitHub Actions, attest e validação por claim)

Objetivo e cenários

Caso real: pipeline GitHub Actions que produz uma imagem e gera uma attestation (provenance) contendo metadata do builder (SHA do repo, runner, workflow). Queremos validar não só assinatura, mas também atributos do build — por exemplo, que a imagem veio da branch main e foi construída por GitHub Actions do nosso org.

GitHub Actions: build, sign e attest

Contexto: workflow que faz build, push, sign (keyless) e cria uma attestation com cosign attest usando predicate de in-toto.

O que faz: compila imagem, faz push, gera assinatura keyless (fulcio) e grava uma attestation com claims que descrevem a build.

name: build-and-sign
on: [push]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Build image
        run: |
          IMAGE=registry.example.com/myapp:${{ github.sha }}
          docker build -t $IMAGE .
          echo "IMAGE=$IMAGE" >> $GITHUB_ENV
      - name: Push image
        run: |
          docker push $IMAGE
      - name: Sign image keyless (fulcio)
        env:
          COSIGN_EXPERIMENTAL: "" # dependendo da versão
        run: |
          cosign sign --keyless $IMAGE
      - name: Create attestation
        run: |
          echo '{"builder":"github-actions","repo":"'${{ github.repository }}'","ref":"'${{ github.ref }}'","sha":"'${{ github.sha }}'"}' > predicate.json
          cosign attest --keyless --predicate predicate.json $IMAGE

O que acontece: imagem é assinada e uma attestation (predicate.json) é anexada. Cosign keyless usa Fulcio e Rekor para log de transparência; a attestation pode ser verificada por quem tiver a política de aceitação.

Verificador avançado (CronJob) que valida claims)

Contexto: um CronJob dentro do cluster executa periodicamente cosign verify --attestation e, se a attestation cumprir requisitos (por exemplo, builder == github-actions e repo na allowlist), extrai digest e atualiza Constraint; caso contrário, alerta via Slack/Prometheus.

O que faz: executa verificação avançada e atualiza a Constraint do Gatekeeper com digests aprovados.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: image-verifier
spec:
  schedule: "*/5 * * * *"
  jobTemplate:
    spec:
      template:
        spec:
          serviceAccountName: verifier-sa
          containers:
            - name: verifier
              image: registry.example.com/tools/cosign-verifier:latest
              env:
                - name: REGISTRY
                  value: registry.example.com
                - name: CONSTRAINT_NAME
                  value: approved-image-digests
              volumeMounts: []
          restartPolicy: OnFailure

O que acontece: o container executa um script que lista tags, para cada tag roda "cosign verify --keyless --attestation" e valida o predicate (checa JSON). Se ok, extrai digest e atualiza o Constraint. Esse container precisa ter cosign e kubectl configurados (ServiceAccount e RBAC).

Rego para checar claims (exemplo)

Contexto: além de checar digest, você pode exigir que a attestation inclua um claim específico. Neste pattern, o CronJob só popula approvedDigests para imagens que passaram nos critérios; Gatekeeper continua simples. Se quiser checar claims diretamente no Rego, sua fonte de verdade precisa incluir os claims no parâmetro do Constraint.

# parâmetro do Constraint poderia conter objetos {digest: "sha256:...", predicate: {builder:..., repo:...}}
# Exemplo de checagem em Rego (snippet dentro do ConstraintTemplate rego acima):
# assume input.parameters.approvedEntries é uma lista de objetos {digest, builder, repo}

# função hipotética
allowed_by_claims(image, entries) {
  contains(image, "@sha256:")
  parts := split(image, "@")
  digest := parts[1]
  some i
  entries[i].digest == digest
  entries[i].builder == "github-actions"
  startswith(entries[i].repo, "myorg/")
}

O que acontece: essa lógica exige que a fonte de dados (atualizada pelo verificador) contenha os claims que você aceita e vincule digest a claims.

Debugging e troubleshooting

Erros comuns e armadilhas

Lista prática de problemas que você vai encontrar:

  • Gatekeeper rejeita pods porque imagem não usa digest (use sempre @sha256:... no deploys declarativos).
  • CASO: cosign verify falha por versão incompatível do cosign ou flags diferentes (sempre pin na versão em CI e verificador do cluster).
  • Problemas de RBAC: o verificador precisa permissão para patch em Constraints; sem isso, a lista nunca é atualizada.
  • Condições de corrida ao patch: use lógica de merge/patch que mescle arrays ao invés de sobrescrever; considere usar uma controller com lock.
  • Key mismatch: se você usar chave pública em vez do keyless, mismatch entre chave usada para sign e a pública no verificador resulta em verify fail.
Dica prática: durante a implementação inicial, deixe Gatekeeper em modo permissivo (breakglass) ou direcione para namespaces de staging até automatizar o verificador e confirmar digests corrigidos.

Comandos e logs úteis

Como investigar problemas de verificação e políticas:

  • Logs do Gatekeeper: kubectl -n gatekeeper-system logs deploy/gatekeeper-audit
  • Verificar Constraint: kubectl get K8sImageVerified -o yaml
  • Debug cosign: cosign verify --key cosign.pub --verbose registry.example.com/myapp:1.2.3
  • Inspecionar o conteúdo do attestation: cosign verify-attestation --keyless --format json registry.example.com/myapp:1.2.3

Solucoes para latencia e escala

Em clusters grandes, atualizar um único Constraint com centenas de digests pode ser ineficiente. Padrões para mitigar:

  • Particione Constraints por aplicação/namespace.
  • Use um CRD (ex: ImageVerification) com entradas por imagem; Gatekeeper pode consultar essa fonte para validação por nome de imagem em vez de varrer um grande array.
  • Indexed lookups: mantenha mapas digest->true em parâmetros menores.

Conclusao pratica

Resumo de passos para implantação segura

Passos práticos para adotar esse padrão com mínimo risco:

  1. Pin nas versões do cosign e da imagem do verificador; crie secrets para chaves quando usar keypair.
  2. Modifique pipelines CI para assinar e (ideal) gerar attestations com metadata.
  3. Implemente um verificador que consome signatures/attestations e popula uma fonte de verdade (Constraint, CRD ou ConfigMap) que Gatekeeper lê.
  4. Implemente ConstraintTemplate + Constraint e teste em ambiente de staging com imagens de teste.
  5. Monitore logs e métricas do verificador e do gatekeeper; automatize alarmes para assinaturas inválidas ou falhas de sync.

Notas finais e boas práticas

Combinar verificação em CI e política no admission é a prática recomendada: CI falha rápido, Gatekeeper garante defesa em runtime. Proteja suas chaves quando usar keypair; considere keyless para reduzir surface area, mas saiba que isso implica confiar na cadeia Fulcio/Rekor. Documente o fluxo para a equipe e automatize rotinas de rotação e recuperação.

Implementar esse padrão exige coordenação entre times de plataforma e de CI. Comece pequeno (um namespace ou app crítico) e expanda quando automática e robustamente comprovado. A segurança da cadeia de build é incremental: cada camada adicional reduz risco e aumenta confiança.

Assuntos: Kubernetes, Docker, cosign, opa, gatekeeper, github-actions

Leia também