In the 09-16 post about testing CDK without deploying (English twin: CDK Assertions Without Deploy), the point of comparison was the assertions library: you write Template.fromStack and Match.objectLike to prove your stack does what you, the author of that stack, intended. That works fine for what it is. The problem is that "what the author intended" and "what the organization requires" are different sets, and the overlap between them is usually smaller than anyone wants to admit.
Nobody writes an unencrypted bucket or an Action: "*" policy on purpose. It sneaks in on the last commit before a deploy, reviewed by someone who trusted the author because the author "always catches that stuff." The stack's own assertion doesn't catch it, because it's testing the intent of whoever wrote it — and whoever wrote it wasn't testing against a rule they didn't know existed.
That's the gap CloudFormation Guard fills: a declarative rules engine that runs against any synthesized JSON/YAML template without knowing — or needing to know — who wrote the stack or what they meant by it. This post uses cfn-guard 3.2.1 (released 2026-08-25) installed locally, validating fixed YAML fixtures — no AWS account, no Docker, no cdk synth run on this host.
Installing cfn-guard without touching credentials
The binary is standalone: download, extract, run. No API calls, no AWS_* environment variables anywhere in sight.
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
Worth flagging a version quirk before going further: the official guide still lists 3.1.2 as the current CLI version (doc last updated 2025-07-30). The GitHub release has been at 3.2.1 since August. The docs describe the tool's shape correctly; for the actual binary version, trust the release tag, not the string printed on the install page.
The subcommands that exist on this CLI: validate, test, parse-tree, rulegen, completions. If you've seen migrate mentioned in an old README somewhere, it's not in this build's --help — don't assume a flag exists just because a three-year-old blog post used it.
validate, exit codes, and the gap the docs leave open
validate checks data (--data, a file or directory) against rules (--rules, a .guard file or directory). It accepts --show-summary / -S (all|pass|fail|skip|none) to control what gets printed, --output-format / -o (single-line-summary|json|yaml|junit|sarif) to plug into other tooling, and --type CFNTemplate to declare the payload type.
What matters for automation is the exit code, and that's exactly where AWS's documentation falls short — it mentions "exit status 0 on success" and stops there. In the 3.2.1 source (guard/src/commands/mod.rs) and confirmed on this host, the real mapping is:
| Exit | Meaning |
|---|---|
| 0 | PASS, or every applicable rule SKIPped |
| 19 | at least one rule FAILed |
| 5 | engine/CLI error (malformed rule, unreadable file) |
If your CI step checks if: failure() expecting exit 1, it never sees the 19. The job goes green with a template that violates policy. That's the difference between the gate existing and the gate existing only in the intent of whoever wrote the workflow.
Org-wide rules in the Guard DSL
The business rule for this example is easy to state and annoying to cover completely: no Allow statement may use Action: "*" or Action: "s3:*", and every AWS::S3::Bucket must declare SSEAlgorithm as AES256, aws:kms, or aws:kms:dsse.
The annoying part is that "IAM with a wildcard" isn't a single resource shape. AWS::IAM::Policy (standalone), Properties.Policies[] embedded in AWS::IAM::Role, and AWS::IAM::RolePolicy (a separate resource) are three distinct ways the same policy ends up in a template — and a rule written for only the first type simply doesn't see the other two. A compact sketch, matching the org.guard used in the tests below:
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 and no_iam_rolepolicy_wildcard_actions
# repeat the same check, swapping the query for
# Properties.Policies[*].PolicyDocument.Statement[*] (Role) and
# 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 [...] works whether Action is a single string or a list — it's Guard's IN operator over a query, not a "contains substring" trick. Failing fixture (excerpt):
WildPolicy:
Type: AWS::IAM::Policy
Properties:
PolicyDocument:
Statement:
- Effect: Allow
Action: "*"
Resource: "*"
OpenBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: org-unencrypted-example
Equivalent compliant fixture:
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
What the rule catches — and where it leaks
I ran the org.guard file above against several variations on the same idea, no AWS account, no network. The result explains what "declarative rules over any template" covers, and what it lets through, better than any slide would.
Actionas a string or a list, doesn't matter:Action: "s3:*"andAction: [s3:*]both fail the same rule (FAIL,EXIT=19).some Action[*] in [...]normalizes both shapes.- A bucket without
BucketEncryptionfails, full stop: even though S3 has applied SSE-S3 by default since 2023-01-05, a template that omits the property still comes backFAIL/EXIT=19. Guard reads the text of the template, not the service's runtime behavior — if the property isn't there, as far as Guard is concerned it doesn't exist. That's a predictable false positive, not a bug: declare the encryption explicitly and the problem goes away. aws:kms:dssepasses onorg.guard, but watch third-party rulesets: this example's rule includes all three validSSEAlgorithmvalues, so DSSE passes (PASS/EXIT=0). AWS's own public registry rule (S3_BUCKET_SERVER_SIDE_ENCRYPTION_ENABLED) only accepts["aws:kms","AES256"]— a bucket usingaws:kms:dssewould fail there even though it's a valid CloudFormation configuration. If you import third-party rulesets, read the accepted-values list before assuming it tracks the current spec.Fn::Ifwrapped aroundBucketEncryptionfails, because Guard doesn't resolve intrinsics: a template withBucketEncryption: {Fn::If: [Cond, {...}, !Ref AWS::NoValue]}fails because, to the parser, the key underBucketEncryptionisFn::If, notServerSideEncryptionConfiguration— the rule doesn't "look inside" the conditional for the true branch.- An
AWS::IAM::ManagedPolicywithAction: "*"is invisible to a rule that only selectsAWS::IAM::Policy: it's the same coverage hole as AWS's own registry —IAM_POLICY_NO_STATEMENTS_WITH_FULL_ACCESSonly reports onAWS::IAM::Policy. An otherwise identicalManagedPolicyis never evaluated by that rule:SKIP, and the job endsPASS/EXIT=0. Coverage by resource type has to be deliberate, not assumed from the rule's name. - A template with only an
AWS::SNS::Topicagainstorg.guard: none of the four rules finds an applicable resource — all fourSKIP, overall statusSKIP,EXIT=0. A ruleset that never matches anything still passes CI, and the log looks no different from a ruleset that passed because the template is correct. Worth adding a regression test (cfn-guard test) that confirms at least one real fixture triggers each rule — otherwise the gate exists on paper only.
Guard is not the assertions library with extra steps
Worth stating the boundary outright instead of leaving it implicit: the CDK assertions library and Guard look like they solve similar problems and don't, in practice. Template.fromStack + Match.objectLike prove that this stack, written by this author, matches this author's intent — it's a unit test on the synthesis output, in TypeScript/Python, inside the same repo as the stack.
Guard doesn't know who wrote the template or what they meant by it. It consumes JSON or YAML — the output of cdk synth, a template checked into the repo, whatever shows up — and applies the same rule to any stack that produces that format, from any team, in any IaC language that emits CloudFormation. It's the difference between "I tested what I wrote" and "the organization tests what I wrote, without trusting me to remember to." The two layers are complementary: assertions remain the right tool for testing a stack's own conditional logic; Guard is how you enforce policy that doesn't depend on every author remembering it.
Wiring it into CI: fail on 19, not on 1
The typical gate runs cdk synth (or uses a template already checked into the repo) and then validates the result against the org's ruleset:
- 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 # if the job already has Node/CDK; otherwise validate the checked-in YAML
$BIN validate --data cdk.out --rules org-rules/ --type CFNTemplate --show-summary fail
Three details decide whether this step protects anything or just decorates the workflow:
- Pin the binary version.
install-guard.shwithout-v, and the Docker image.../cloudformation-guard:latest, both track themainbranch, not a release. The GitHub Action tag mentioned in the README (aws-cloudformation/cloudformation-guard@action-v0.0.5) is the Action's version, not the CLI's — don't assume it ships 3.2.1. - Treat 19 as failure, not 1. If your CI runner only recognizes exit 1 as an error, the job goes green with a rule in
FAIL. - Use
--output-format junitorsarifwhen your CI already renders those formats. That puts the violation list in the reviewer's own pipeline dashboard instead of a lonely number in a log.
What Guard doesn't cover
Guard only sees the template's text at the moment validate runs. It doesn't replace any layer that exists after deploy — it just keeps certain violations from ever reaching that layer:
- Runtime drift is not Guard's problem. Someone swaps a policy via the console after deploy, or the service applies a default the template never declared — CloudFormation drift detection and AWS Config cover that, by comparing the account's current state against the template or against continuous rules. These are complementary layers, not substitutes: one runs before deploy without touching the account, the others run after, with account access.
- CloudFormation Hooks bring the same rule-engine idea inside the account, at provisioning time — another enforcement point, not a repeat of the CI gate.
- Logical IDs generated by CDK are hashes. Rules that filter by
Resources.MyBucketdon't survive a resynthesize; filter byType, as in the sketch above. s3:Get*is nots3:*. An exactin ["*", "s3:*"]doesn't block a narrower per-service wildcard — if the org wants to block any<service>:*, the query needs a regex operator, not a fixed two-string list.- Access granted outside the template — a manual
PutRolePolicy, a managed policy attached later through the console — never shows up in Guard, because it was never in the file Guard read.
Recommendation
If your organization already has at least two rules that today live only in a PR checklist ("never a bucket without encryption," "never a wildcard in IAM"), it's worth trading the checklist for a versioned .guard file and a CI step that fails on exit 19 — the entry cost is a static binary and zero credentials, and the payoff is not depending on whoever reviewed the PR having remembered the rule that day. Where it's not worth it: if policy changes per service or per case-by-case exception faster than your review cadence can keep the .guard file in sync, maintaining the ruleset can end up costing more than the gate is worth — in that scenario, a documented exception process (tested with cfn-guard test) matters more than bolting on one more rule.