# tfsec Cheat Sheet

> **Tool:** tfsec
> **Category:** Security & Compliance
> **Verified against:** tfsec v1.28.14, flags verified via `tfsec --help` run locally, 2026-08-29
> **Official docs:** https://aquasecurity.github.io/tfsec/latest/

## What it is and where it fits 🎯

tfsec is Terraform-specific static analysis for security misconfigurations — it reads `.tf` source directly
and needs no `plan`/`apply`, so it can run in a pre-commit hook or on every PR with zero cloud credentials or
state access required.

> [!IMPORTANT]
> **tfsec is in maintenance mode.** Aqua Security merged its entire check library into Trivy (`trivy config`)
> and directs engineering effort there now — tfsec still works and receives security fixes, but new checks
> land in Trivy first. For a brand-new setup, evaluate `trivy config` (see the Trivy cheat sheet's companion
> IaC page) before reaching for tfsec. This page documents tfsec because it's still widely deployed in
> existing pipelines that haven't migrated yet, and understanding it is genuinely useful for reading those
> pipelines' configs.

## Installation

```bash
curl -s https://raw.githubusercontent.com/aquasecurity/tfsec/master/scripts/install_linux.sh | bash
# installs to /usr/local/bin by default — needs sudo, or download the release binary directly and place it
# on your own PATH (e.g. ~/.local/bin) if you don't have sudo:
curl -sL -o tfsec https://github.com/aquasecurity/tfsec/releases/latest/download/tfsec-linux-amd64
chmod +x tfsec && mv tfsec ~/.local/bin/

go install github.com/aquasecurity/tfsec/cmd/tfsec@latest    # if you have a Go toolchain

tfsec --version
```

## Scanning

```bash
tfsec .                                       # scan the current directory recursively
tfsec /path/to/terraform
tfsec --minimum-severity HIGH .               # CRITICAL, HIGH, MEDIUM, or LOW
```

## CI gating — the inverted default worth remembering

```bash
tfsec . --soft-fail                            # run checks but always exit 0 — reporting-only mode
tfsec .                                        # default: non-zero exit if any (non-ignored) issue is found
```

> [!NOTE]
> tfsec fails the build by default on any finding — `--soft-fail` is the opt-out. That's the **opposite**
> default from Trivy and Grype (both default to exit 0, requiring an explicit `--exit-code`/`--fail-on` flag
> to gate). Mixing tfsec into a pipeline alongside those tools without noticing this asymmetry is a common
> source of "wait, why did only this one step fail" confusion.

## Excluding checks and paths

```bash
tfsec . --exclude aws-s3-enable-bucket-encryption,aws-s3-enable-versioning
tfsec . --exclude-path .terraform --exclude-path modules/vendored
tfsec . --exclude-downloaded-modules            # skip anything pulled into .terraform/modules
```

Inline suppression, directly above the offending resource block:

```hcl
#tfsec:ignore:aws-s3-enable-bucket-encryption
resource "aws_s3_bucket" "example" {
  # ...
}
```

## Output formats

```bash
tfsec . --format json --out results.json
tfsec . --format sarif --out results.sarif      # for GitHub code scanning
tfsec . --format junit --out results.xml
tfsec . --concise-output                         # summary only, no code snippets
tfsec . --include-passed                          # show passing checks too, not just failures
```

Sample `lovely` (default) output shape — illustrative, actual check IDs depend on what's in the scanned
Terraform:

```
  result #1 CRITICAL S3 bucket does not have logging enabled.
────────────────────────────────────────────────────────────────
  main.tf:3-8
────────────────────────────────────────────────────────────────
   3    resource "aws_s3_bucket" "example" {
   4      bucket = "my-bucket"
   5    }
────────────────────────────────────────────────────────────────
  ID            aws-s3-enable-logging
  Impact        There is no way to determine the access or usage of this bucket
  Resolution    Add a logging block to the resource
────────────────────────────────────────────────────────────────

  24 passed, 1 potential problem(s) detected.
```

## Custom checks

```bash
tfsec . --custom-check-dir ./custom-checks       # load org-specific Rego/JSON checks alongside built-ins
```

A custom check is a small JSON/YAML document describing a Rego-backed rule:

```json
{
  "checks": [
    {
      "code": "CUS001",
      "description": "Ensure all resources are tagged with an owner",
      "requiredTypes": ["resource"],
      "requiredLabels": ["aws_instance", "aws_s3_bucket"],
      "severity": "MEDIUM",
      "matchSpec": {
        "name": "tags",
        "action": "notContains",
        "value": "owner"
      },
      "errorMessage": "Resource is missing a required 'owner' tag"
    }
  ]
}
```

> [!TIP]
> **Org-specific tagging/naming-convention checks are one of the highest-value custom checks a platform team
> can add** — generic public rulesets naturally can't know your org's internal tagging policy, but a missing
> `owner` or `cost-center` tag is exactly the kind of thing that turns into a FinOps headache (see this
> series' FinOps content) months after the resource ships, when nobody remembers who created it.

## tfsec vs. the other IaC scanners in this series

| | tfsec | Checkov | `trivy config` |
|---|---|---|---|
| IaC coverage | Terraform only | Terraform, CloudFormation, K8s, Helm, Dockerfile, Ansible, ARM/Bicep, more | Terraform, CloudFormation, K8s, Helm, Dockerfile, Azure ARM |
| Maintenance status | Maintenance mode (checks frozen, folded into Trivy) | Actively developed | Actively developed |
| Default CI behavior | Fails on any finding | Fails on any finding | Reports only (needs `--exit-code`) |
| Custom check language | JSON/YAML + optional Rego | Python or YAML | Rego |
| Also does vuln/secret/SBOM scanning | No | Partial (`sca_package`/`sca_image`/`secrets` frameworks) | Yes, in the same binary |

## Adoption checklist for a new Terraform repo

- [ ] Run `tfsec .` once, unfiltered, to see the real baseline finding count before deciding on a rollout plan
- [ ] Decide severity threshold for the initial hard gate (`--minimum-severity HIGH` is a common starting point)
- [ ] Add inline `#tfsec:ignore` comments only for genuinely reviewed, accepted exceptions — not as a way to silence noise quickly
- [ ] Wire the CI step in with SARIF output so findings show up as inline PR annotations, not just a pass/fail badge
- [ ] Revisit the severity threshold quarterly as the backlog of lower-severity findings gets addressed

## Real-world scenario: gating new Terraform without a wall of legacy findings

A large existing Terraform codebase adopting tfsec for the first time often has too many pre-existing findings
to fix before the first PR merges. `--soft-fail` on the whole repo defeats the purpose; a more targeted
approach scopes the hard gate to what actually changed:

```bash
# CI step: only fail on findings in files this PR actually touched
git diff --name-only origin/main...HEAD -- '*.tf' > changed.txt
if [ -s changed.txt ]; then
  tfsec $(cat changed.txt | xargs dirname | sort -u)
fi
```

> [!TIP]
> This diff-scoped pattern (report on everything, hard-fail only on what changed) is the same shape
> Semgrep's `--baseline-commit` and Checkov's `--baseline` solve more natively — tfsec doesn't have a
> first-class equivalent flag, so scoping by changed files is the practical workaround.

## Real-world scenario: GitHub Actions CI recipe

```yaml
# .github/workflows/tfsec.yml
name: tfsec
on: [pull_request]
jobs:
  tfsec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aquasecurity/tfsec-sarif-action@v0.1.4
        with:
          sarif_file: tfsec.sarif
      - uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: tfsec.sarif
```

## A sample of frequently-hit built-in checks

| Check ID | Catches |
|---|---|
| `aws-s3-enable-bucket-encryption` | An S3 bucket with no server-side encryption configured |
| `aws-s3-enable-versioning` | An S3 bucket with versioning disabled — no protection against accidental overwrite/delete |
| `aws-s3-enable-logging` | An S3 bucket with no access logging — no audit trail if it's ever accessed unexpectedly |
| `aws-ec2-no-public-ingress-sgr` | A security group rule allowing ingress from `0.0.0.0/0` |
| `aws-iam-no-policy-wildcards` | An IAM policy granting `Action: "*"` or `Resource: "*"` — overly broad by default |
| `azure-storage-default-action-deny` | An Azure Storage account with a network ACL default action of `Allow` instead of `Deny` |
| `google-storage-bucket-encryption-customer-key` | A GCS bucket not using a customer-managed encryption key |

tfsec's `--help` doesn't expose a flag to list every check from the CLI itself — check the online checks
catalog linked from the official docs for the full, current list. The built-in catalog grows and check IDs
occasionally get renamed across releases, so treat the table above as a taste of what tfsec catches, not an
exhaustive reference.

## Common pitfalls

- **Assuming the same "reports, doesn't gate" default as Trivy/Grype** — see the NOTE above; tfsec is the
  opposite.
- **Not knowing tfsec is in maintenance mode** — planning new custom-check investment against tfsec's Rego
  format when that investment would carry forward more safely against Trivy's actively developed check bundle.
- **Inline `#tfsec:ignore` comments accumulating with no review process** — like any suppression mechanism,
  worth periodically auditing (`grep -rn 'tfsec:ignore'`) rather than letting them silently pile up.

## Exit codes

`0` clean, or `--soft-fail` set · non-zero when findings exist and `--soft-fail` is not set.

## Full flag reference

| Flag | What it does |
|---|---|
| `-m`, `--minimum-severity` | Only report at/above this severity: `CRITICAL`, `HIGH`, `MEDIUM`, `LOW` |
| `-e`, `--exclude` | Comma-separated rule IDs to skip for this run |
| `-E`, `--exclude-ignores` | Comma-separated rule IDs to exclude from *ignore-rule* processing — forces them to always report even if inline-ignored |
| `--exclude-path` | Folder path to skip; repeatable, evaluated in specification order |
| `--exclude-downloaded-modules` | Skip findings inside anything pulled into `.terraform/modules` |
| `--force-all-dirs` | Scan every directory below the target, even ones with no `.tf` files detected |
| `-s`, `--soft-fail` | Run checks but always exit 0 |
| `-G`, `--disable-grouping` | Show every individual result instead of grouping similar findings together |
| `--include-passed` | Show passing checks too, not just failures |
| `--include-ignored` | Show checks suppressed by an inline ignore, instead of hiding them entirely |
| `--no-ignores` | Ignore every inline `#tfsec:ignore` comment for this run — useful for a periodic full-strength audit |
| `--no-module-downloads` | Don't download remote modules — scan only what's already vendored locally |
| `--migrate-ignores` | Rewrite old-style ignore codes to the current ID structure |
| `--code-theme` | `light` or `dark` — cosmetic, for the annotated code snippets in terminal output |
| `--run-statistics` | Print a summary statistics table of findings by severity/rule |
| `-w`, `--workspace` | Which Terraform workspace's ignore limits apply, when a project uses multiple workspaces |
| `--rego-only` | Run only custom Rego-based checks, skip the built-in Go checks entirely |
| `--print-rego-input` | Dump the JSON representation fed into Rego policies — useful when debugging a custom Rego check |

## Troubleshooting

**"tfsec found nothing, but I know this file has an issue"** — check whether the resource lives inside a
downloaded module (`.terraform/modules/`); `--exclude-downloaded-modules` is off by default in most setups,
but a project-level config might have it on. Also confirm the file has a `.tf` extension tfsec actually scans
recursively from the given directory — `tfsec .` from the wrong working directory silently scans nothing.

**"A check keeps firing even though I added `#tfsec:ignore`"** — the comment must be directly above the
resource block, not the argument line; a misplaced ignore comment is a silent no-op, not an error.

**"Results look different between my laptop and CI"** — confirm both are running the exact same tfsec
version (`tfsec --version`); check bundle content and defaults have changed release to release even during
the maintenance-mode period, so a floating "latest" in CI vs. a pinned local install is a common source of
drift.

## Real-world scenario: migrating an existing tfsec CI gate to Trivy

Given tfsec's maintenance-mode status, a team with tfsec already wired into CI has a natural migration path
that doesn't require a big-bang cutover:

```bash
# Run both in parallel for a sprint, comparing findings
tfsec . --format json --out tfsec-results.json
trivy config . --format json --output trivy-results.json
# Once trivy's findings are confirmed to be a superset (or an acceptable equivalent), drop the tfsec step
```

Because Trivy's Terraform checks are literally sourced from tfsec's own check library, this comparison
usually converges quickly — most differences come down to check-ID naming rather than genuinely different
coverage, but it's worth confirming on a security-critical repo before removing the older gate entirely.

## When to reach for something else

`trivy config` now covers the same Terraform check library tfsec does, actively maintained, plus other IaC
formats in the same run — see the Trivy IaC/SBOM cheat-sheet page. Checkov remains a genuinely distinct
option for teams whose IaC footprint includes Ansible, Bicep, or Serverless Framework, which neither tfsec
nor Trivy's config scanner cover as of this writing.
