Architecture testing framework for Terraform — write Python rules that verify structure, conventions, and policy.
Project description
terrifying
Architecture testing framework for Terraform. Write rules in Python that verify your Terraform code follows structural conventions, best practices, and organisational policies — the equivalent of ArchUnit or Checkstyle but for infrastructure code.
Why
Existing tools like tflint, checkov, and terrascan are opinionated security scanners. terrifying is a general-purpose framework for writing your own architecture rules: enforce file size limits, resource count limits, parameterisation patterns, required tags, naming conventions, and anything else your team cares about. OPA and c7n_left policy engines are also supported as first-class integrations.
Installation
pip install terrifying
Quickstart
Create terrifying.yml in your project root:
terraform:
path: ./infra
rules:
max_resources_per_file:
max_resources: 10
required_tags:
tags:
- Environment
- Team
variables_have_descriptions: {}
outputs_have_descriptions: {}
Run pytest — terrifying is discovered automatically as a pytest plugin:
$ pytest -v
terrifying::max_resources_per_file PASSED
terrifying::required_tags FAILED
infra/main.tf [required_tags] Resource aws_s3_bucket.data is missing required tag 'Team'
terrifying::variables_have_descriptions PASSED
terrifying::outputs_have_descriptions PASSED
Each rule becomes a named test item. Violations are shown as test failures. No test files to write.
Configuration (terrifying.yml)
# Directory containing .tf files to check (relative to terrifying.yml)
terraform:
path: ./infra
# Built-in rules — omit a rule to disable it
rules:
max_resources_per_file:
max_resources: 10
max_lines_per_file:
max_lines: 150
resource_file_naming:
pattern: "^[a-z_]+\\.tf$"
no_hardcoded_values:
allowed_attributes:
- ami
variables_have_descriptions: {}
outputs_have_descriptions: {}
required_tags:
tags:
- Environment
- Team
# Custom rules — Python files in this directory are loaded automatically
custom:
path: ./rules
# Policy engine integrations (binaries must be on PATH)
# Plain path — no parameters
policies:
opa: ./policies/opa
c7n: ./policies/c7n
# Or nested format with global and per-policy parameters
policies:
opa:
path: ./policies/opa
params:
required_tags: [Environment, Team] # available as input.params in Rego
policies:
require_encryption:
params:
algorithm: AES256 # overrides global for this policy only
c7n:
path: ./policies/c7n
params:
required_tags: [Environment, Team] # injected as Jinja2 variables
policies:
require-retention:
params:
min_retention_days: 90
Built-in rules
| Rule | Default | What it checks |
|---|---|---|
max_resources_per_file |
10 | Resources per .tf file |
max_lines_per_file |
150 | Lines per .tf file |
resource_file_naming |
— | File name matches a regex pattern |
no_hardcoded_values |
— | Attribute values are references, not literals |
variables_have_descriptions |
— | All variables have a description |
outputs_have_descriptions |
— | All outputs have a description |
required_tags |
— | All resources carry required tag keys |
Writing a custom rule
Rules are plain Python classes. The rule ID is derived automatically from the class name.
# rules/no_count.py
from terrifying.core import Rule, Violation, TerraformContext
class NoCount(Rule):
"""Flags resources using count; prefer for_each."""
def check(self, context: TerraformContext) -> list[Violation]:
violations = []
for resource in context.resources:
if "count" in resource.attributes:
violations.append(Violation(
rule=self.rule_id,
file=resource.file,
message=f"{resource.type}.{resource.name} uses count; prefer for_each",
))
return violations
NoCount → rule_id = "no_count" — shown as terrifying::no_count in pytest output.
OPA integration
Place .rego files in the directory configured under policies.opa. Each policy file is evaluated with a full input document containing the Terraform context and any configured params.
Input document shape
{
"files": [...],
"resources": [
{
"type": "aws_s3_bucket",
"name": "data",
"file": "infra/main.tf",
"attributes": { "bucket": "my-bucket", "tags": { "Environment": "prod" } }
}
],
"params": {
"required_tags": ["Environment", "Team"]
}
}
input.params contains the merged result of global params and any per-policy overrides defined in terrifying.yml.
Writing a Rego policy
Policies must use package terrifying and populate the deny set. Use input.params to access configured parameters:
# policies/opa/require_tags.rego
package terrifying
import rego.v1
deny contains msg if {
resource := input.resources[_]
tag := input.params.required_tags[_]
not resource.attributes.tags[tag]
msg := sprintf("Resource %v.%v is missing required tag '%v'", [resource.type, resource.name, tag])
}
Per-policy parameter overrides
Global params apply to every policy file. Override them for a specific policy by name (matching the filename without .rego):
# terrifying.yml
policies:
opa:
path: ./policies/opa
params:
required_tags: [Environment, Team] # applies to all policies
policies:
require_encryption:
params:
algorithm: AES256 # only require_encryption.rego sees this
Inside require_encryption.rego, input.params will be {"required_tags": ["Environment", "Team"], "algorithm": "AES256"}.
Requires opa on PATH. If absent, a single opa_unavailable test item is reported.
c7n integration
Place c7n YAML files in the directory configured under policies.c7n. All .yml files are treated as Jinja2 templates — configured params are passed as template variables before the rendered YAML is handed to c7n-left. The original file is never modified.
Writing a c7n policy template
Use {{ variable }} for substitution and {% for %} for loops:
# policies/c7n/require_tags.yml
policies:
{% for tag in required_tags %}
- name: require-{{ tag | lower }}-tag
resource: terraform.aws_s3_bucket
filters:
- "tag:{{ tag }}": absent
{% endfor %}
With required_tags: [Environment, Team] configured in terrifying.yml, terrifying renders this to:
policies:
- name: require-environment-tag
resource: terraform.aws_s3_bucket
filters:
- "tag:Environment": absent
- name: require-team-tag
resource: terraform.aws_s3_bucket
filters:
- "tag:Team": absent
Per-policy parameter overrides
Override params for a specific c7n policy file by its filename (without .yml):
# terrifying.yml
policies:
c7n:
path: ./policies/c7n
params:
required_tags: [Environment, Team]
policies:
require-retention:
params:
min_retention_days: 90 # only require-retention.yml sees this
Plain YAML with no Jinja2 syntax passes through to c7n-left unchanged.
Requires c7n-left on PATH (pip install c7n-left). If absent, a c7n_unavailable test item is reported.
Bundled policy library
terrifying ships a curated library of ~370 shift-left-compatible AWS best-practice policies covering CIS, FSBP, PCI-DSS, NIST 800-53, and Control Tower requirements — available in both OPA/Rego and c7n-left formats.
Browse and add policies
# Interactive TUI — browse by tag, select policies, preview delta, confirm
terrifying add # requires: pip install terrifying[tui]
# Non-interactive — add specific policies by ID
terrifying add rds-storage-encrypted s3-bucket-server-side-encryption-enabled
# Choose engine (default: both)
terrifying add rds-storage-encrypted --engine rego
terrifying add rds-storage-encrypted --engine c7n
# Preview what would be added without writing anything
terrifying add rds-storage-encrypted --dry-run
When you add a policy, terrifying:
- Copies the
.rego/.ymlfile to your configured policies directory - Prompts for any configurable params (e.g.
required_tags) and writes them toterrifying.yml - Shows a diff of
terrifying.ymlchanges before applying
List available policies
terrifying list # all policies, human-readable
terrifying list --engine rego # Rego only
terrifying list --tag fsbp # filter by compliance framework
terrifying list --tag s3 --tag high # multiple tags (AND)
terrifying list --format json # full catalog as JSON
terrifying list --format json --tag s3 # filtered JSON
Use --format json to get machine-readable output with full descriptions, tags, and resource types — useful for scripting or AI-assisted policy selection.
Available tags include compliance frameworks (fsbp, cis-benchmark, pci-dss, nist-800-53, control-tower-mandatory, control-tower-strongly-recommended, control-tower-elective), AWS services (s3, rds, ec2, …), severities (high, medium, low), and engines (rego, c7n).
Claude Code skill
Generate a Claude Code slash command that teaches Claude how to write terrifying policies and use the bundled library:
terrifying skill > .claude/commands/terrifying.md
Once installed, Claude Code users can invoke /terrifying to get context-aware help writing Rego and c7n policies, adding bundled policies, and configuring terrifying.yml.
CLI
For use in scripts or CI pipelines without pytest:
terrifying check ./infra # text output, exit 1 on errors
terrifying check ./infra --format json # JSON array of violations
Context model
Custom rules receive a TerraformContext:
| Attribute | Type | Description |
|---|---|---|
context.files |
list[TerraformFile] |
One entry per .tf file |
context.resources |
list[Resource] |
All resources across all files |
file.path |
Path |
Absolute path to the file |
file.resources |
list[Resource] |
Resources defined in this file |
file.variables |
list[Variable] |
Variable blocks |
file.outputs |
list[Output] |
Output blocks |
file.locals |
list[Local] |
Local values |
file.module_calls |
list[ModuleCall] |
Module calls |
file.line_count |
int |
Total lines in the file |
Parse errors
Files that cannot be parsed produce a terrifying::parse_errors test item listing the affected files. Parsing continues for all other files.
Development
make install # install dependencies with uv
make fmt # auto-format with black
make lint # black --check, ruff, pylint
make test # pytest with branch coverage (95% minimum)
make ci # lint + test
Licence
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file terrifying-0.1.1.tar.gz.
File metadata
- Download URL: terrifying-0.1.1.tar.gz
- Upload date:
- Size: 98.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
c57ebe92d57b215a533901a07b7eedfaea918bab6a2721f6898ef0fe470e0eab
|
|
| MD5 |
35ed409ae7f1a7ce7143dd87d193011d
|
|
| BLAKE2b-256 |
07ac982d8a8a7498489d699f6a14b7d4915a1d1a7bf26ac8ae91fb2c4fd4bc1e
|
File details
Details for the file terrifying-0.1.1-py3-none-any.whl.
File metadata
- Download URL: terrifying-0.1.1-py3-none-any.whl
- Upload date:
- Size: 277.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: uv/0.11.8 {"installer":{"name":"uv","version":"0.11.8","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6508220f20c234ef3bfbfd2e5c1401c5b1c3d3970749cf150598628bf5984408
|
|
| MD5 |
17bb9af5f0dd8c71864f60250721ee26
|
|
| BLAKE2b-256 |
5734b3dfd43d4c6aa978e80430e8366a341bb2f21300cc52011a30273f6df340
|