Sign in
Menu
Sign up
Docs
Check before applyctfkit

Commands

ctfkit by compliance.tf has two analysis commands, scan and report, plus verify-lock, config, catalog pull, version and the governed apply commands. scan checks a configuration or a plan. report reads the module cache after init. Both write the same finding format and follow the same exit code contract.

It is for platform engineers wiring ctfkit into CI who need every flag, file name and exit code. It describes the newest ctfkit release, which the Quickstart installs.

  • ctfkit scan (available): pre_plan over .tf files, post_plan over plan JSON.
  • ctfkit report: post_init over .terraform/modules.
  • Exit 0 nothing at or above --fail-on, 1 unsuppressed findings at or above it, 2 could not run, or refused.
  • Settings come from --config, else .ctfkit.yaml in the discovery root, else built-in defaults.
  • ctfkit catalog pull (available): downloads the full control catalog with your compliance.tf registry token, on a paid plan. The catalog built into the binary holds the CIS AWS Benchmark v6.0 controls.
  • scan, report, verify-lock and config never run terraform or tofu, open no network connection and need no credentials. catalog pull downloads from the compliance.tf registry. The governed apply, evidence ship and runtask serve commands run terraform or call AWS KMS, S3 or HCP Terraform: see Network and credentials.

Stages

A stage is the point in the Terraform or OpenTofu workflow where an analyzer runs. The diagram shows where each stage runs. The table shows what each stage can and cannot see, which depends on what Terraform has produced by then.

Where ctfkit by compliance.tf runs in a Terraform workflow Timeline of a Terraform or OpenTofu run. Before init, the pre_plan stage runs ctfkit scan over the .tf files in a pre-commit hook or the GitHub Action. After init, post_init runs ctfkit report over the module cache in the GitHub Action or a Spacelift after_init hook. After plan and show -json, post_plan runs ctfkit scan --plan plan.json in the GitHub Action or a Spacelift after_plan hook. pre_apply and post_apply are reserved and run no analyzers. Edit .tf files initterraform or tofu plan + show -jsonwrites plan.json apply pre_planctfkit scan <dir>Sees.tf files as written: no init, no credentialsRuns inpre-commit hook · GitHub ActionStart here post_initctfkit reportSeesmodule cache that init downloadedRuns inGitHub Action · Spacelift after_init hook post_planctfkit scan --plan plan.jsonSeesresolved plan values: tags, exposure, changesRuns inGitHub Action · Spacelift after_plan hook pre_applyreserved: the same plan JSON, right before apply post_applyreserved: would read state LegendTerraform or OpenTofu stepctfkit stageStart hereReserved, no analyzers
StageCommandSeesCannot see
pre_planscan.tf and .tofu files as written: module sources, version pins, provider blocks and literal arguments. Needs no init and no credentialsanything evaluated. HCL is parsed and not evaluated, so var.*, local.*, functions, count, for_each and module outputs do not resolve
post_initreportthe module cache in .terraform/modules after init, including the manifest each compliance.tf module carriesevaluated values or provider data. scan --stage post_init exits 2 and points to report
post_planscanthe plan JSON, with variables, count, for_each and nested modules resolved. Needs init, credentials and a successful planvalues unknown until apply; a rule that depends on one stays silent

pre_apply and post_apply are reserved names with no analyzers; scan --stage post_apply exits 2.

scan

ctfkit scan [flags] [DIR | PLAN_FILE | FILE.tf ...]

Positional arguments: a directory is the configuration directory; .tf, .tf.json, .tofu and .tofu.json files are an explicit pre_plan file list (what the pre-commit hook passes); any other .json file is the plan.

ctfkit scan plan.json                          # post_plan
ctfkit scan --plan plan.json --chdir ./infra   # the same, findings anchored to ./infra
ctfkit scan --stage pre_plan ./terraform/      # pre_plan over a directory
ctfkit scan --stage pre_plan main.tf vpc.tf    # pre_plan over a file list
ctfkit scan plan.json --format sarif --out findings.sarif
ctfkit scan plan.json --format sarif,json,junit --out-dir out/
ctfkit scan plan.json --format spacelift       # writes ctfkit-scan.custom.spacelift.json
FlagMeaning
--stageauto (default), pre_plan or post_plan. auto picks post_plan when a plan is given, pre_plan otherwise
--planplan JSON from terraform show -json or tofu show -json; a positional .json file means the same
--chdirTerraform configuration directory (default .); a directory positional means the same
--format, -fcomma-separated list of sarif, json, text, pretty (default), junit, spacelift
--outoutput file for exactly one format; - is stdout
--out-dirdirectory for several formats
--fail-onerror (default), warning, note or never
--configsettings file; see Settings file
--exceptionsexception registry (default: .ctf-exceptions.yaml in the configuration directory)
--catalogcontrol catalog YAML, such as the full catalog catalog pull writes, or the literal embedded to pin the catalog compiled into the binary, which holds the CIS AWS Benchmark v6.0 controls. Default: ~/.ctf/control-requirements.yaml when that file exists, else the embedded catalog; pass embedded or an explicit file in CI so a home-directory file cannot change the result. See Control catalog
--remediation-hostframework id whose registry host goes into the suggested module source, for example pci_dss_v40. Default: soc_2, which gives soc2.compliance.tf
--run-id, --commit, --platformrun metadata; detected from the CI environment when not given
--watermarkoptional banner line added to every output
--no-color, --verboseterminal output only

Every single-value flag refuses a second use and an empty value, with exit 2. An adapter that pins --config or --fail-on therefore cannot be overridden by a later flag on the same command line.

ctfkit PATH... means ctfkit scan --stage pre_plan PATH.... The output and exit code are the same. To scan the working directory, type ctfkit .; ctfkit with no path prints the help and exits 0.

report

report reads .terraform/modules after init and reports which controls the compliance.tf modules in the configuration enforce, what the configuration declares itself, and what the report does not prove.

terraform init
ctfkit report                                      # to stdout
ctfkit report --format receipt,sarif,markdown --out-dir out/
ctfkit report --format --readme README.md # inject into README.md
FlagMeaning
--pathmodule root or example directory, repeatable (default .)
--format, -fcomma-separated list of markdown (default), receipt, sarif, spacelift
--outoutput file for a single format; - is stdout
--out-dirdirectory for several formats
--readmeinject the section between the markers in this file
--markersmarker pair START,END (default <!-- CTFKIT_REPORT_START --> and <!-- CTFKIT_REPORT_END -->)
--tests-jsonoutput of tofu test -json or terraform test -json for this root, written by an earlier step
--fail-onerror (default), warning, note or never
--config, --exceptions, --catalogas for scan, except that --exceptions defaults to .ctf-exceptions.yaml in the working directory, when present
--run-id, --commit, --platform, --watermarkas for scan

--readme and --markers need markdown in --format; without it the run exits 2. The receipt records which controls the compliance.tf modules enforce at module build time, as read from the manifests written at init. It describes the configuration and says nothing about live resources. The receipt is not signed; receipt signing is planned.

Other commands

CommandStatusDoes
ctfkit verify-lock [DIR]availablechecks that the lock file of every deployable root installs the compliance.tf provider wrappers; see Lock file rules. It has no --fail-on: any finding exits 1
ctfkit gate plan, gate sign, verdict verify, applynot availablegoverned apply: plan a candidate, decide and sign its verdict, check it, apply the sealed plan. See Governed apply in the ctfkit repository
ctfkit evidence shipnot availableuploads a run's evidence chains and writes its receipt; see evidence
ctfkit runtask servenot availableserves an advisory HCP Terraform or Terraform Enterprise run task; see adapters/hcp
ctfkit config initavailablewrites a starter .ctfkit.yaml in the working directory; see Settings file
ctfkit config validate [FILE]availablechecks a settings file and prints the values ctfkit reads from it
ctfkit catalog pullavailabledownloads the full control catalog from the compliance.tf registry to ~/.ctf/control-requirements.yaml, where scan and report read it; see Control catalog
ctfkit versionavailableprints the release, the commit, the build date, the Go version and the platform

The commands marked not available ship in the release but cannot be used: it needs a signed organization profile and floor statement (--org, --profile, --floor), and compliance.tf does not issue them. scan, report, verify-lock, config and catalog pull need none of them.

Commands in the ctfkit repository has every flag of every command.

Network and credentials

scan, report, verify-lock, config and version run offline: they never run terraform or tofu and need no network or credentials. report --readme and config init write files.

catalog pull downloads the full control catalog from registry.compliance.tf with your compliance.tf registry token. It runs no terraform. It is the only analysis-side command that opens a network connection; scan and report stay offline, also when they read the catalog it wrote.

These commands run terraform, call AWS or serve HCP Terraform:

  • gate plan and apply run terraform, which may use the network and your cloud credentials.
  • verdict verify runs only terraform version, init and show -json on the sealed plan. gate sign runs the same three and calls AWS KMS through --signer, with AWS credentials.
  • evidence ship calls AWS KMS through --signer and uploads to S3, with AWS credentials. It runs no terraform, and the receipt it writes is not signed.
  • runtask serve pulls the plan JSON from HCP Terraform or Terraform Enterprise. Then it sends the task result back.

terraform init configures the backend, so with a remote backend every command that runs it uses the network and your backend credentials.

Findings, SARIF, JSON, receipts and the control catalog are not signed. Compliance.tf does not sign, attest or verify anything for you. Receipt signing is planned.

Output formats

Format--out-dir file nameUse
sariffindings.sarifGitHub code scanning and any SARIF 2.1.0 reader
jsonfindings.jsonOPA, Conftest and your own scripts
textfindings.txtlogs and job summaries
prettyfindings.pretty.txta terminal (the default)
junitfindings.junit.xmlCI test report views
spaceliftalways in the working directory: ctfkit-scan.custom.spacelift.json from scan, ctfkit.custom.spacelift.json from reportthe Spacelift plan policy

report writes report.md, receipt.json and findings.sarif into --out-dir.

One format goes to stdout or to --out. Several need --out-dir. The SARIF output is a profile of SARIF 2.1.0: a normal SARIF log plus a ctf.* property bag on the run and on each result, carrying control ids, frameworks and the compliance.tf module that closes the gap. Any SARIF reader loads it as ordinary SARIF. The profile is a draft and its field names can still change.

The JSON output carries catalogVersion and findings, where each finding is a SARIF result: ruleId, level, message.text, locations, properties with the control ids in ctf.controlIds and, when an exception covers it, suppressions.

Exit codes and --fail-on

CodeMeaning
0nothing at or above --fail-on
1unsuppressed findings at or above --fail-on
2could not run, or refused: an unreadable or invalid plan, configuration, catalog, settings or exceptions file, or an invalid flag

--fail-on takes error (default), warning, note or never. The flag wins over the CTFKIT_FAIL_ON environment variable, which wins over fail_on in the settings file. never keeps the exit code at 0 on findings, which suits a step whose output another tool judges, such as OPA.

A scan or report whose format list is exactly spacelift returns 0 on findings, because the Spacelift plan policy reads the verdict in the file. It still returns 2 when it could not run.

Hosts and registry tokens

Compliance.tf serves modules from two kinds of host. Your organization's host, such as bobthecorp.compliance.tf, serves the builds your organization configured. Framework hosts, such as soc2.compliance.tf or hipaa.compliance.tf, serve the build for one framework, which also applies your organization's Baseline rules when they are enforced; Module request resolution has the details.

--remediation-host picks the framework host that suggested fixes point to. It takes a framework id and defaults to soc_2, so fixes name soc2.compliance.tf; pci_dss_v40 gives pcidss.compliance.tf. Fixes always name a framework host: --remediation-host cannot point them at your organization's host, so replace the host yourself if your modules come from there. An unknown id exits 2 and lists the valid ones. The embedded catalog accepts acsc_essential_eight, acsc_ism_2023, aws_control_tower, aws_genai_v2, aws_well_architected_v10, cccs_medium, cfr_part_11, cis_v500, cis_v600, cis_v71_ig1, cis_v80_ig1, cisa_cyber_essentials, eu_gmp_annex_11, fedramp_low_rev_4, fedramp_moderate_rev_4, ffiec, gdpr, hipaa_final_omnibus_2013, hipaa_security_2003, iso_27001_2013, iso_27001_2022, nis2, nist_800_171_rev_2, nist_800_53_rev_4, nist_800_53_rev_5, nist_csf_v11, nist_csf_v2, nydfs_23, pci_dss_v321, pci_dss_v40, rbi_cyber_security, rbi_itf_nbfc, soc_2.

init downloads these modules with your compliance.tf registry token, which you create on the Registry tokens page of the compliance.tf platform. Terraform reads it from TF_TOKEN_<host>, with the dots in the host name replaced by underscores, so set one variable for every compliance.tf host in your module sources: TF_TOKEN_bobthecorp_compliance_tf for bobthecorp.compliance.tf, TF_TOKEN_soc2_compliance_tf for soc2.compliance.tf. An organization token carries your organization's plan on every host, so the same token value can go in each variable. ctfkit never reads the token.

Settings file

ctfkit looks for settings in this order:

  1. --config FILE, if given.
  2. .ctfkit.yaml in the discovery root. For scan that is the configuration directory: --chdir, or the directory argument, else the working directory. For report it is the working directory.
  3. Built-in defaults.

When scan falls back to the working directory, it names that directory in one stderr line after the run. The settings file is part of the scanned repository, so anyone who can open a pull request can edit it. A run that has to hold regardless of repository content passes --config, --exceptions, --catalog and --fail-on from outside the repository. The GitHub Action and the Spacelift hooks do this.

allowed_endpoints decides which module source hosts count as compliance.tf. Leave the key out and any host ending in .compliance.tf counts, including your organization's host, the same as running with no settings file. Set it and only the hosts you list count: a module from bobthecorp.compliance.tf then needs its own entry. An empty list, allowed_endpoints: [], is a configuration error, exit 2. ctfkit makes no request to these hosts; it only compares module sources against them.

# Leave allowed_endpoints out to admit any host ending in .compliance.tf.
# List hosts to admit only those:
# allowed_endpoints:
#   - soc2.compliance.tf
#   - bobthecorp.compliance.tf

# Optional allowlists. Empty means everything is allowed.
allowed_resources: []
allowed_providers:
  - registry.terraform.io/hashicorp/aws

# Tag policy for planned resources.
tags:
  - key: Owner
    required: true
    resource_types: [aws_s3_bucket]     # empty means every taggable resource
  - key: CostCentre
    pattern: "^CC-[0-9]{4}$"            # RE2; allowed_values is the alternative
required_tags: [Environment]            # shorthand for required tags entries

# Provider types compliance.tf wraps here: aws, azurerm, google. When set,
# scan --stage pre_plan also runs the lock file rules.
# wrapped_providers: [aws]

# Exit-1 threshold when neither --fail-on nor CTFKIT_FAIL_ON is set.
fail_on: error

ctfkit config init writes a starter .ctfkit.yaml in the working directory. It leaves allowed_endpoints commented out, so a fresh file gives the same findings as no file. If a .ctfkit.yaml already exists, it writes nothing and exits 2; ctfkit config init --force replaces the file. ctfkit config validate .ctfkit.yaml checks a settings file and prints the values ctfkit reads from it.

Exceptions

An exception waives a finding for one resource, with an approver and a review date. The registry is a YAML file: --exceptions FILE, or else .ctf-exceptions.yaml in the same discovery root as the settings file. For scan that is the configuration directory, and for report the working directory. No file means no exceptions, which is not an error.

schema_version: "1.0"

exceptions:
  - rule: ctf.coverage.module_source
    resource: aws_s3_bucket.logs
    justification: "Scratch bucket for ETL output; it stays a raw aws_s3_bucket on purpose."
    approved_by: compliance@bobthecorp.example
    approval_ticket: SEC-4711
    review_date: "2027-06-01"
KeyRule
control or ruleexactly one per entry: a control id, or a rule ID such as ctf.policy.floating_ref. A finding with control ids is waived only when a control entry covers each of them; a rule entry reaches only findings with no control id, such as the provider and pinning checks. The json and sarif outputs list a finding's control ids in ctf.controlIds. A control entry matches only ids the loaded catalog holds; the embedded catalog holds the CIS AWS Benchmark v6.0 controls only, so for any other control load the full catalog or use a rule entry
resourcerequired; the address of the resource the exception covers
approved_by, approvalsat least one approver across the two; approvals is a list
review_daterequired, YYYY-MM-DD in UTC. The exception holds through the end of that day
identry id; falls back to approval_ticket
justificationfree text, kept with the entry
schema_versionoptional; when present its major version must be 1

An entry without an approver or without a review date never suppresses anything. An expired entry stops suppressing the day after its review date, and the finding comes back. These are errors, exit 2: an entry that names both control and rule, or neither; an entry with no resource; two entries for the same control or rule and the same resource; a file that does not parse. Keys ctfkit does not read, such as organization or compensating_controls, are allowed and kept for your own records.

A suppressed finding still appears in the output with its suppressions entry and counts as suppressed in the summary line; it does not affect the exit code or the Spacelift verdict.

To write a first exception and see it suppress a finding, follow Step 5: Waive one finding in the Quickstart.

Control catalog

Control ids, framework clauses and replacement module sources come from a control catalog. The catalog compiled into the binary holds the CIS AWS Benchmark v6.0 controls only, each with its CIS v6.0 clauses, and the compliance.tf module entries. It needs no network or token, so every run has a catalog, and scan prints its version on the summary line, for example catalog 2026.11.1 (embedded).

The full catalog, with every control and every framework, is a separate file. Name it with --catalog FILE, or place it at ~/.ctf/control-requirements.yaml, which ctfkit catalog pull writes. --catalog embedded pins the embedded catalog and ignores the home-directory file; the GitHub Action and the Spacelift hooks pass it by default.

ctfkit catalog pull                         # latest full catalog to ~/.ctf/control-requirements.yaml
ctfkit catalog pull --version 2026.1109.0   # one published version
ctfkit catalog pull --out catalog.yaml      # any file, then pass --catalog catalog.yaml
FlagMeaning
--versioncatalog version to download (default: the latest)
--outfile to write (default: ~/.ctf/control-requirements.yaml)
--hostregistry host to download from (default registry.compliance.tf); it receives that host's token, so name only a registry you trust

catalog pull authenticates with the token Terraform uses for the registry host: TF_TOKEN_registry_compliance_tf, else the credentials.tfrc.json that terraform login writes (~/.terraform.d/, or %APPDATA%\terraform.d\ on Windows). It checks the download against the X-Catalog-Sha256 header the registry sends, validates it as --catalog would, and replaces the destination in one step, so scan and report never read half a file. The hash catches a damaged or truncated download; trust in where the catalog came from rests on the TLS connection to the registry, and the catalog is not signed. The full catalog needs a paid plan; without one the registry answers 403. It exits 0 on success and 2 on any failure, with nothing written, so scan and report go on reading the catalog they read before. Full catalog versions read YYYY.MMDD.N, the date the catalog was built and a build number, for example 2026.1109.0; they always sort after the embedded catalog's version.

With the embedded catalog, a control outside CIS v6.0 is a control the catalog does not hold:

  • ?disable= of such a control is reported as ctf.policy.disabled_control_unknown, an error with no control id.
  • ctf.coverage.delegation_unresolved names the id in its message and carries no control id; report still completes.
  • ctf.risk.sensitive_change_* findings still fire, without a control id or frameworks.
  • An exception keyed by control does not match it; use a rule entry, or load the full catalog.

Switching between the embedded and the full catalog changes the control ids on findings: a control outside CIS v6.0 gains its id, so ?disable= of it becomes ctf.policy.disabled_control, and a rule exception written for the id-less finding stops matching. Review your exceptions when you switch.

A ctf.coverage.module_source finding on a raw resource lists only the controls the suggested module enforces. The catalog can place more controls on the same resource type, and those are left off, because the finding does not claim the module closes them.

Next steps

  • GitHub Actions - run scan and report on every pull request with pinned settings.
  • Spacelift - run them as stack hooks and feed the verdict to a plan policy.
  • Reference analyzers - every rule ID, its stage and its limits.

On this page

Ask AI about this

Help improve this page