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 é:
| Exit | Significado |
|---|---|
| 0 | PASS, ou todas as regras aplicáveis em SKIP |
| 19 | pelo menos uma regra em FAIL |
| 5 | erro 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.
Actioncomo string ou como lista, tanto faz:Action: "s3:*"eAction: [s3:*]falham a mesma regra (FAIL,EXIT=19). Osome Action[*] in [...]normaliza os dois formatos.- Bucket sem
BucketEncryptionfalha, ponto: mesmo sabendo que o S3 aplica SSE-S3 por padrão desde 05/01/2023, o template que omite a propriedade continua emFAIL/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:dssepassa noorg.guard, mas cuidado com regras de terceiros: a regra deste exemplo inclui as três opções válidas deSSEAlgorithm, 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 comaws:kms:dssefalharia 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::Ifem volta deBucketEncryptionfalha, e não é acidente: o Guard não resolve intrínsecas. Um template comBucketEncryption: {Fn::If: [Cond, {...}, !Ref AWS::NoValue]}falha porque, para o parser, a chave sobBucketEncryptionéFn::If, nãoServerSideEncryptionConfiguration— a regra não "olha para dentro" do condicional em busca do ramo verdadeiro.AWS::IAM::ManagedPolicycomAction: "*"passa direto por uma regra que só selecionaAWS::IAM::Policy: é o mesmo buraco de cobertura do registro oficial da AWS —IAM_POLICY_NO_STATEMENTS_WITH_FULL_ACCESSreporta apenas emAWS::IAM::Policy. UmManagedPolicyidêntico em conteúdo simplesmente não é avaliado por essa regra:SKIP, e o job termina emPASS/EXIT=0. Cobertura por tipo de recurso precisa ser deliberada, não assumida a partir do nome da regra.- Template só com
AWS::SNS::Topiccontra oorg.guard: nenhuma das quatro regras encontra um recurso aplicável — todas emSKIP, status geralSKIP,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:
- Fixe a versão do binário.
install-guard.shsem-ve a imagem Docker.../cloudformation-guard:latestseguem a branchmain, 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. - 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. - Use
--output-format junitousarifquando 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.MeuBucketnão sobrevivem a uma resynthesize; filtre porType, como no sketch acima. s3:Get*não és3:*. Umin ["*", "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 —
PutRolePolicymanual, 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.