✓ Human-authored analysis; AI used for formatting and proofreading.
What cfn_nag does well
cfn_nag is a static analysis tool for CloudFormation templates. It looks for patterns that may indicate insecure infrastructure:
- IAM rules with wildcards
- Security groups open to 0.0.0.0/0
- Missing encryption
- Missing access logs
- Password literals in templates
It runs before deployment, in CI pipelines, and catches problems at authoring time. It has a mature rule set, and supports per-resource suppression via metadata annotations.
For CloudFormation shops, cfn_nag is a valuable first line of defense.
The gap: templates are not reality
cfn_nag scans what you intend to deploy. It cannot answer:
"Is my bucket public right now?" — The template might be correct, but someone changed the bucket policy via the console, the CLI, or another IaC tool.
"How long has it been public?" — cfn_nag is stateless. It sees one template at one point in time. It doesn't track duration.
"Is this GCP bucket public?" — cfn_nag only understands CloudFormation. It doesn't evaluate Terraform, GCP, Azure, DNS records, or any non-AWS resource.
"Can I prove this bucket is compliant?" — cfn_nag produces warnings and failures. It doesn't produce a structured compliance report with regulatory citations, temporal evidence, or a formal reasoning chain.
What Stave does differently
Stave evaluates observed infrastructure state. The input is a JSON snapshot of what exists, captured by an extractor that reads cloud APIs. The evaluation happens offline, deterministically, against YAML controls compiled to CEL predicates.
Duration tracking
Finding: CTL.S3.PUBLIC.001 — my-bucket
First unsafe: 2026-01-01T00:00:00Z
Last seen: 2026-01-11T00:00:00Z
Duration: 240 hours (threshold: 168 hours)
cfn_nag says "this is wrong." Stave says "this has been wrong for 240 hours and your SLA is 168 hours." That distinction matters for compliance. A bucket that was public for 5 minutes during a deploy is different from one that's been public for 6 months.
Multi-cloud, multi-domain
Stave evaluates 246 controls across 29 domains without any code changes to the engine:
| Domain | Controls | Vendor |
|---|---|---|
| AWS S3 | 149 | aws |
| AWS IAM | 340 | aws |
| GCP Cloud Storage | 7 | gcp |
| DNS records | 3 | any (vendor-agnostic) |
cfn_nag only understands CloudFormation. Stave evaluates any JSON-represented infrastructure. The engine doesn't know what cloud provider the data came from. It evaluates properties.* paths on assets with open-ended type and vendor strings.
Formal reasoning chain
stave apply --trace audit_trace.json
The logic trace records every decision: exemption check, predicate evaluation, threshold check, verdict. For passing resources, this is "Proof of Pass". It is positive evidence that the control was evaluated and the asset was compliant. cfn_nag doesn't explain why something passed.
Compound risk detection
cfn_nag evaluates rules independently. Stave detects compound patterns:
- COMPOUND.001: Public access + wildcard IAM actions = lateral movement
- COMPOUND.002: Encryption enabled + public access = false security
- COMPOUND.003: VPC endpoint + no endpoint policy = wormhole
Individual rules pass. The combination is dangerous. cfn_nag can't see this.
Comparison
| Capability | cfn_nag | Stave |
|---|---|---|
| What it scans | CloudFormation templates | Observed infrastructure state (any JSON) |
| When it runs | Pre-deployment (template scan) | Pre-deployment (CI gate) AND post-deployment (continuous) |
| Duration tracking | No | Yes — SLA-based, temporal evidence |
| Multi-cloud | No (CloudFormation only) | Yes — AWS, GCP, DNS, any vendor |
| Offline/air-gapped | Yes | Yes |
| Rule format | Ruby classes | YAML + CEL predicates (no code) |
| Custom rules | Write Ruby, inherit BaseRule | Write YAML, make gencontrol scaffolds it |
| Suppression | Per-resource metadata annotation | Acknowledged exceptions with compensating controls |
| Output formats | Text, JSON | Text, JSON, SARIF |
| CI integration | GitHub Action, CodePipeline | Exit codes, baselines, gating, diff, fix-loop, SARIF |
| Pre-deployment blocking | Yes (template scan) | Yes (gate on new/any violations, block PRs) |
| Compliance mapping | No | HIPAA citations, CIS references |
| Compound risk | No | 3 compound detectors |
| Proof of pass | No | Logic trace with reasoning chain |
| SBOM | No | SPDX + CycloneDX generation |
| Deterministic | Mostly | Fully (--eval-time flag, policy hash) |
The static analysis ceiling
cfn_nag is a linter. Linters evaluate rules independently against individual resources. This creates a hard ceiling:
No compound risk detection. cfn_nag can tell you a bucket is public (W warning) and that an IAM policy has wildcard actions (F failure) but it cannot tell you that the combination is the S3+IAM lateral movement pattern present in the majority of documented AWS breaches. Each rule runs in isolation. There is no cross-resource, cross-rule reasoning.
No temporal reasoning. A linter sees one template at one point in time. It cannot track that a misconfiguration has persisted for 6 months, or that it was briefly introduced and fixed within the SLA window. Duration is invisible.
No negative-space detection. cfn_nag can check what's in the template. It cannot check what's missing from infrastructure that exists outside CloudFormation such as console changes, SDK calls, cross-account policies, or resources created by other tools.
False positives from expression resolution. Static analyzers must parse the template language such as CloudFormation intrinsic functions, Terraform HCL expressions, for_each loops, try() fallbacks. When the parser can't fully resolve a complex expression, it guesses wrong. An example: Trivy reports "Bucket does not have versioning enabled" on a Terraform module where versioning IS enabled via for_each + try() in a locals block. The HCL expression is too complex for the parser to resolve, so it defaults to "not enabled." Stave doesn't have this problem. It evaluates the deployed bucket's actual versioning.enabled property, not the expression that computes it.
These aren't bugs in cfn_nag or Trivy. They're fundamental limits of static template analysis. A linter parses text. Stave evaluates state.
Where cfn_nag wins
- CloudFormation-native: Understands Fn::If, Fn::FindInMap, parameters, conditions, pseudo-functions. Stave doesn't parse CloudFormation. It evaluates observed state.
- Pre-deployment template analysis: Catches problems before any infrastructure exists. Stave needs an observation snapshot. It gates deployments against existing state, not proposed templates.
-
Template-level suppressions: Inline
Metadata.cfn_nag.rules_to_suppresson the resource itself. Convenient for developers. - Parameter injection: Can substitute parameter values for static analysis. Stave evaluates actual values, not parameterized ones.
- Zero infrastructure required: Runs on a template file alone. Stave requires an extractor to have produced observation snapshots first.
Where Stave goes beyond
1. Observed state, not intended state
Someone changes a bucket policy via the console. cfn_nag doesn't see it. It only scans templates. Stave evaluates the actual bucket configuration captured by the extractor.
2. Time is the threat
cfn_nag is binary: pass or fail. Stave adds the time dimension. A bucket that was public for 5 minutes is different from one public for 6 months. Duration tracking with SLA thresholds turns binary findings into risk-prioritized evidence.
3. Controls are data, not code
cfn_nag rules are Ruby classes that inherit from BaseRule. Adding a new check means writing Ruby code, understanding the object model, and testing against template fixtures.
Stave controls are YAML files with unsafe_predicate blocks. Adding a new check means writing 20 lines of YAML. The Policy Forge scaffolds it with validation and test fixtures in 30 seconds:
make gencontrol ID=CTL.S3.NEW.001 NAME="My Check" \
FIELD=properties.storage.access.public_read \
REMEDIATION="Enable Public Access Block."
4. Formal proof system
Stave is not a scanner. It's a policy evaluation engine for JSON-represented infrastructure. The engine is cloud-agnostic. Asset types and vendors are open strings. Adding Route53 or Kubernetes support requires only YAML controls and a contract extension with zero engine changes.
5. Pre-deployment CI gating
Stave isn't just post-deployment. It blocks insecure changes in CI before they reach production:
# Save current state as baseline
stave ci baseline save --in current-eval.json --out baseline.json
# After a proposed change, evaluate the new state
stave apply --format json > proposed-eval.json
# Block the PR if it introduces new violations
stave ci gate --policy fail_on_new_violation \
--in proposed-eval.json --baseline baseline.json
# Exit 3 = new violations introduced → PR blocked
The ci diff command shows exactly what changed:
stave ci diff --current proposed-eval.json --baseline baseline.json
# Shows: new findings, resolved findings, unchanged findings
This catches configuration changes that make existing infrastructure less secure even if the CloudFormation template looks fine. A template can be valid and still produce an insecure configuration when applied to existing state.
When to use which
Use cfn_nag when you want to lint CloudFormation templates at authoring time. It understands CloudFormation intrinsic functions and catches patterns before any infrastructure exists.
Use Stave when you want to evaluate actual infrastructure state, track duration, prove compliance, gate deployments against observed state, or evaluate multi-cloud infrastructure.
Use both for defense in depth: cfn_nag catches template-level patterns at authoring time, Stave catches everything in observed state including console changes, drift, configurations no template defined, and proposed changes that would degrade existing security posture.
The difference
cfn_nag asks: "Does this template follow security patterns?"
Stave asks: "Is this infrastructure safe, for how long has it been unsafe, and can I prove it?"
One is a linter. The other is a formal proof system. They solve different problems at different layers.
Top comments (0)