Skip to main content

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

NoCountrule_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:

  1. Copies the .rego / .yml file to your configured policies directory
  2. Prompts for any configurable params (e.g. required_tags) and writes them to terrifying.yml
  3. Shows a diff of terrifying.yml changes 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

terrifying-0.1.1.tar.gz (98.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

terrifying-0.1.1-py3-none-any.whl (277.2 kB view details)

Uploaded Python 3

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

Hashes for terrifying-0.1.1.tar.gz
Algorithm Hash digest
SHA256 c57ebe92d57b215a533901a07b7eedfaea918bab6a2721f6898ef0fe470e0eab
MD5 35ed409ae7f1a7ce7143dd87d193011d
BLAKE2b-256 07ac982d8a8a7498489d699f6a14b7d4915a1d1a7bf26ac8ae91fb2c4fd4bc1e

See more details on using hashes here.

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

Hashes for terrifying-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 6508220f20c234ef3bfbfd2e5c1401c5b1c3d3970749cf150598628bf5984408
MD5 17bb9af5f0dd8c71864f60250721ee26
BLAKE2b-256 5734b3dfd43d4c6aa978e80430e8366a341bb2f21300cc52011a30273f6df340

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page