Todos os artigos

// Knowledge.log — 技術記事

CloudFormation Guard: validando templates com regras org-wide antes do deploy, sem tocar a conta

Cfn-guard 3.2.1 local, sem credenciais AWS: regras org-wide de IAM e S3 que falham o build antes do deploy, com exit codes reais e gate de CI.

No post de 16/09 sobre testando CDK sem deploy a régua era a assertions library: você escreve Template.fromStack e Match.objectLike para provar que a sua stack faz o que você, autor da stack, pretendia. Funciona bem para isso. O problema é que "o que o autor pretendia" e "o que a organização exige" são conjuntos diferentes, e não é raro que a interseção seja menor do que todo mundo gostaria.

Ninguém escreve de propósito um bucket sem criptografia ou uma policy com Action: "*". Acontece num último commit antes do deploy, revisado por alguém que confiou no autor porque o autor "sempre presta atenção nisso". A assertion da própria stack não pega, porque ela testa a intenção de quem escreveu — e quem escreveu não pretendia validar uma regra que nem sabia que existia.

É aqui que entra o CloudFormation Guard: um motor de regras declarativas que roda sobre qualquer template JSON/YAML sintetizado, sem saber (e sem precisar saber) quem escreveu a stack nem o que ela pretendia fazer. Este post usa o cfn-guard 3.2.1 (release de 25/08/2026) instalado localmente, validando fixtures YAML fixas — sem conta AWS, sem Docker, sem cdk synth neste host.

Instalando o cfn-guard sem tocar em credenciais

O binário é standalone: baixa, extrai, roda. Nenhuma chamada de API, nenhuma variável AWS_* no ambiente.

curl -fsSL -o cfn-guard.tgz \
  https://github.com/aws-cloudformation/cloudformation-guard/releases/download/3.2.1/cfn-guard-v3-x86_64-ubuntu-latest.tar.gz
tar -xzf cfn-guard.tgz
./cfn-guard-v3-x86_64-ubuntu-latest/cfn-guard --version
# cfn-guard 3.2.1

Vale registrar uma pegadinha de versão antes de seguir: o guia oficial ainda referencia 3.1.2 como versão corrente da CLI (atualização de doc de 30/07/2025). O release no GitHub já está em 3.2.1 desde agosto. A doc descreve o formato da ferramenta corretamente; para a versão do binário, confie na tag do release, não na string impressa na página de instalação.

Os subcomandos que existem nesta CLI: validate, test, parse-tree, rulegen, completions. Se você viu migrate citado em algum README antigo, ele não está no --help desta build — não invente a flag esperando que ela apareça.

validate, exit codes, e a lacuna que a doc não fecha

validate avalia dados (--data, arquivo ou diretório) contra regras (--rules, arquivo .guard ou diretório). O comando aceita --show-summary / -S (all|pass|fail|skip|none) para controlar o que é impresso, --output-format / -o (single-line-summary|json|yaml|junit|sarif) para integrar com outra ferramenta, e --type CFNTemplate para declarar o tipo de payload.

O que importa para automação é o exit code, e é exatamente onde a documentação da AWS fica incompleta: ela menciona "exit status 0 em sucesso" e para aí. No código-fonte da 3.2.1 (guard/src/commands/mod.rs) e neste host, o mapeamento real é:

ExitSignificado
0PASS, ou todas as regras aplicáveis em SKIP
19pelo menos uma regra em FAIL
5erro de engine/CLI (regra malformada, arquivo ilegível)

Se o seu step de CI verifica if: failure() esperando exit 1, ele nunca vê o 19. O job passa verde com um template que viola a política. Isso não é um detalhe de rodapé — é a diferença entre o gate existir e o gate existir só na intenção de quem escreveu o workflow.

Regras org-wide na Guard DSL

A regra de negócio deste exemplo é simples de enunciar e chata de cobrir por completo: nenhuma statement Allow pode usar Action: "*" ou Action: "s3:*", e todo AWS::S3::Bucket precisa declarar SSEAlgorithm como AES256, aws:kms ou aws:kms:dsse.

A parte chata é que "IAM com wildcard" não é um único tipo de recurso. AWS::IAM::Policy (standalone), Properties.Policies[] embutido em AWS::IAM::Role, e AWS::IAM::RolePolicy (recurso separado) são três formas distintas de a mesma policy acabar no template — e uma regra escrita só para o primeiro tipo simplesmente não vê os outros dois. Um sketch compacto, coerente com o org.guard usado nos testes abaixo:

let iam_policies = Resources.*[ Type == "AWS::IAM::Policy" ]
let iam_roles = Resources.*[ Type == "AWS::IAM::Role" ]
let iam_role_policies = Resources.*[ Type == "AWS::IAM::RolePolicy" ]
let s3_buckets = Resources.*[ Type == "AWS::S3::Bucket" ]

rule no_iam_policy_wildcard_actions when %iam_policies !empty {
  let violations = %iam_policies[
    some Properties.PolicyDocument.Statement[*] {
      Effect == "Allow"
      some Action[*] in ["*", "s3:*"]
    }
  ]
  %violations empty
  << Violation: AWS::IAM::Policy Allow statements must not use Action "*" or "s3:*" >>
}

# no_iam_role_embedded_wildcard_actions e no_iam_rolepolicy_wildcard_actions
# repetem a mesma checagem trocando a query para
# Properties.Policies[*].PolicyDocument.Statement[*] (Role) e
# Properties.PolicyDocument.Statement[*] (RolePolicy).

rule s3_bucket_encryption_required when %s3_buckets !empty {
  %s3_buckets.Properties.BucketEncryption exists
  %s3_buckets.Properties.BucketEncryption.ServerSideEncryptionConfiguration[*]
    .ServerSideEncryptionByDefault.SSEAlgorithm in ["AES256", "aws:kms", "aws:kms:dsse"]
  << Violation: BucketEncryption.ServerSideEncryptionConfiguration[].ServerSideEncryptionByDefault.SSEAlgorithm must be AES256, aws:kms, or aws:kms:dsse >>
}

some Action[*] in [...] funciona tanto para Action como string única quanto como lista — é o operador IN do Guard sobre uma query, não um "contém substring". Fixture com falha (trecho):

WildPolicy:
  Type: AWS::IAM::Policy
  Properties:
    PolicyDocument:
      Statement:
        - Effect: Allow
          Action: "*"
          Resource: "*"
OpenBucket:
  Type: AWS::S3::Bucket
  Properties:
    BucketName: org-unencrypted-example

Fixture equivalente compatível:

NarrowPolicy:
  Type: AWS::IAM::Policy
  Properties:
    PolicyDocument:
      Statement:
        - Effect: Allow
          Action: [s3:GetObject, s3:ListBucket]
          Resource: [arn:aws:s3:::example-bucket, arn:aws:s3:::example-bucket/*]
EncryptedBucketS3:
  Type: AWS::S3::Bucket
  Properties:
    BucketEncryption:
      ServerSideEncryptionConfiguration:
        - ServerSideEncryptionByDefault:
            SSEAlgorithm: AES256
cfn-guard validate --data fail.yaml --rules org.guard --type CFNTemplate --show-summary all
# Status = FAIL; EXIT=19
cfn-guard validate --data pass.yaml --rules org.guard --type CFNTemplate --show-summary all
# Status = PASS; EXIT=0

O que a régua pega — e onde ela vaza

Rodei o org.guard acima contra várias variações da mesma ideia, sem conta AWS e sem rede. O resultado explica melhor do que qualquer slide o que "regra declarativa sobre qualquer template" cobre e o que ela deixa passar.

  • Action como string ou como lista, tanto faz: Action: "s3:*" e Action: [s3:*] falham a mesma regra (FAIL, EXIT=19). O some Action[*] in [...] normaliza os dois formatos.
  • Bucket sem BucketEncryption falha, ponto: mesmo sabendo que o S3 aplica SSE-S3 por padrão desde 05/01/2023, o template que omite a propriedade continua em FAIL/EXIT=19. O Guard lê o texto do template, não o comportamento em runtime do serviço — se a propriedade não está lá, para o Guard ela não existe. É uma fonte de falso-positivo previsível, não um bug: declare a criptografia explicitamente e o problema desaparece.
  • aws:kms:dsse passa no org.guard, mas cuidado com regras de terceiros: a regra deste exemplo inclui as três opções válidas de SSEAlgorithm, então DSSE passa (PASS/EXIT=0). A regra equivalente do registro público da AWS (S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED) só aceita ["aws:kms","AES256"] — um bucket com aws:kms:dsse falharia lá mesmo sendo uma configuração válida no CloudFormation. Se você importar rulesets de terceiros, leia a lista de valores aceitos antes de assumir que ela acompanha o spec atual.
  • Fn::If em volta de BucketEncryption falha, e não é acidente: o Guard não resolve intrínsecas. Um template com BucketEncryption: {Fn::If: [Cond, {...}, !Ref AWS::NoValue]} falha porque, para o parser, a chave sob BucketEncryption é Fn::If, não ServerSideEncryptionConfiguration — a regra não "olha para dentro" do condicional em busca do ramo verdadeiro.
  • AWS::IAM::ManagedPolicy com Action: "*" passa direto por uma regra que só seleciona AWS::IAM::Policy: é o mesmo buraco de cobertura do registro oficial da AWS — IAM_POLICY_NO_STATEMENTS_WITH_FULL_ACCESS reporta apenas em AWS::IAM::Policy. Um ManagedPolicy idêntico em conteúdo simplesmente não é avaliado por essa regra: SKIP, e o job termina em PASS/EXIT=0. Cobertura por tipo de recurso precisa ser deliberada, não assumida a partir do nome da regra.
  • Template só com AWS::SNS::Topic contra o org.guard: nenhuma das quatro regras encontra um recurso aplicável — todas em SKIP, status geral SKIP, EXIT=0. Um ruleset que nunca casa com nada "passa" no CI com a mesma cara de um ruleset que passou porque o template está correto. Vale colocar um teste de regressão (cfn-guard test) que garanta que pelo menos uma fixture real dispara cada regra — senão o gate existe só no papel.

Guard não é a assertion library com esteroides

Vale reforçar a fronteira em vez de deixar por implícito: a assertions library do CDK e o Guard resolvem problemas parecidos na superfície e diferentes na prática. Template.fromStack + Match.objectLike provam que esta stack, escrita por este autor, corresponde à intenção deste autor — é teste de unidade sobre a própria síntese, em TypeScript/Python, dentro do mesmo repositório da stack.

O Guard não sabe quem escreveu o template nem o que essa pessoa pretendia. Ele consome JSON ou YAML — a saída de cdk synth, um template checado no repositório, o que vier — e aplica a mesma regra a qualquer stack que produza esse formato, de qualquer time, em qualquer linguagem de IaC que gere CloudFormation. É a diferença entre "eu testei o que eu escrevi" e "a organização testa o que eu escrevi, sem confiar em mim para isso". As duas camadas são complementares: a assertion continua sendo o jeito certo de testar lógica condicional da própria stack; o Guard é o jeito de aplicar política que não depende de cada autor lembrar dela.

Colocando no CI: falhar no 19, não no 1

O gate típico roda cdk synth (ou usa um template já checado no repositório) e depois valida o resultado contra o ruleset da organização:

- uses: actions/checkout@v7
- run: |
    curl -fsSL -o cfn-guard.tgz \
      https://github.com/aws-cloudformation/cloudformation-guard/releases/download/3.2.1/cfn-guard-v3-x86_64-ubuntu-latest.tar.gz
    tar -xzf cfn-guard.tgz
    BIN=./cfn-guard-v3-x86_64-ubuntu-latest/cfn-guard
    $BIN --version
    # cdk synth --quiet   # se o job já tem Node/CDK; senão valide o YAML já versionado
    $BIN validate --data cdk.out --rules org-rules/ --type CFNTemplate --show-summary fail

Três detalhes que decidem se esse step protege alguma coisa ou só decora o workflow:

  1. Fixe a versão do binário. install-guard.sh sem -v e a imagem Docker .../cloudformation-guard:latest seguem a branch main, não um release. A tag oficial da GitHub Action citada no README (aws-cloudformation/cloudformation-guard@action-v0.0.5) é a versão da Action, não da CLI — não assuma que ela empacota o 3.2.1.
  2. Trate 19 como falha, não 1. Se o runner do CI só reconhece exit 1 como erro, o job passa verde com regra em FAIL.
  3. Use --output-format junit ou sarif quando o CI já sabe renderizar esses formatos. Isso dá ao revisor a lista de violações no próprio painel do pipeline, em vez de um número solto no log.

Onde a régua do Guard termina

Guard só vê o texto do template no momento do validate. Ele não substitui nenhuma camada que existe depois do deploy — só evita que certas violações cheguem até ela:

  • Drift em runtime não é assunto do Guard. Alguém troca uma policy via console depois do deploy, ou o serviço aplica um default que o template não declarou — CloudFormation drift detection e AWS Config cobrem isso, comparando o estado atual da conta contra o template ou contra regras contínuas. São camadas complementares, não substitutas: uma roda antes do deploy sem tocar na conta, as outras rodam depois, com acesso à conta.
  • CloudFormation Hooks levam a mesma ideia de regra Guard para dentro da conta, no momento do provisionamento — outro ponto de aplicação, não uma repetição do gate de CI.
  • IDs lógicos gerados pelo CDK são hashes. Regras que filtram por Resources.MeuBucket não sobrevivem a uma resynthesize; filtre por Type, como no sketch acima.
  • s3:Get* não é s3:*. Um in ["*", "s3:*"] exato não bloqueia um wildcard mais restrito por serviço parcial — se a organização quer bloquear qualquer <serviço>:*, a query precisa de um operador de regex, não de uma lista fixa de duas strings.
  • Acesso concedido fora do template — PutRolePolicy manual, managed policy anexada depois pelo console — nunca aparece no Guard, porque nunca esteve no arquivo que ele leu.

Recomendação

Se a sua organização já tem pelo menos duas regras que hoje vivem só em checklist de PR ("nunca bucket sem criptografia", "nunca wildcard em IAM"), vale a pena trocar o checklist por um .guard versionado e um step de CI que falha no exit 19 — o custo de entrada é um binário estático e nenhuma credencial, e o retorno é não depender de quem revisou o PR ter lembrado da regra naquele dia. Onde não vale a pena: se a política muda por serviço ou por exceção caso a caso com frequência maior do que a velocidade de revisão do .guard, o custo de manter o ruleset sincronizado pode superar o valor do gate — nesse cenário, um processo de exceção documentado (e testado com cfn-guard test) importa mais do que adicionar mais uma regra ao arquivo.

awscloudformationcdk

// Continue.training — 次のステップ

Conhecimento só conta quando vira prática.

Volte ao artigo, execute os exemplos e compartilhe o que aprendeu.

Explorar mais artigos