├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── dependabot.yml │ ├── e2e.yml │ ├── goreleaser.yml │ └── release.yml ├── .goreleaser.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.md ├── docs ├── README.md ├── alternatives.md ├── configuration.md ├── debug.md ├── environment_variables.md ├── functions.md ├── handling_special_values.md ├── intro.md ├── schema.md └── testing.md ├── examples ├── enforce_bucket_names │ ├── .tflint.d │ │ └── policies │ │ │ ├── bucket.rego │ │ │ └── bucket_test.rego │ ├── .tflint.hcl │ ├── README.md │ └── main.tf ├── enforce_encrypted_devices │ ├── .tflint.d │ │ └── policies │ │ │ ├── device.rego │ │ │ └── device_test.rego │ ├── .tflint.hcl │ ├── README.md │ └── main.tf ├── enforce_modules │ ├── .tflint.d │ │ └── policies │ │ │ ├── module.rego │ │ │ └── module_test.rego │ ├── .tflint.hcl │ ├── README.md │ └── main.tf └── enforce_tags │ ├── .tflint.d │ └── policies │ │ ├── tags.rego │ │ └── tags_test.rego │ ├── .tflint.hcl │ ├── README.md │ └── main.tf ├── go.mod ├── go.sum ├── integration ├── checks │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── data_sources │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── ephemerals │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── imports │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── instance_type │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result-v0.43.0.json │ ├── result.json │ └── result_test.json ├── integration_test.go ├── locals │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── module_calls │ ├── .tflint.hcl │ ├── main.tf │ ├── module │ │ └── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── moved │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── naming_convention │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── outputs │ ├── .tflint.hcl │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── providers │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── removed │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── resources │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── settings │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── tagged │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── variables │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── volume_size │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ │ ├── main.rego │ │ └── main_test.rego │ ├── result.json │ └── result_test.json └── volume_type │ ├── .tflint.hcl │ ├── main.tf │ ├── policies │ ├── main.rego │ └── main_test.rego │ ├── result.json │ └── result_test.json ├── main.go ├── opa ├── config.go ├── config_test.go ├── conversion.go ├── conversion_test.go ├── engine.go ├── engine_test.go ├── functions.go ├── functions_test.go ├── rule.go ├── rule_test.go ├── ruleset.go ├── ruleset_test.go ├── test-fixtures │ └── config │ │ ├── local │ │ └── .tflint.d │ │ │ └── policies │ │ │ └── .gitkeep │ │ ├── root-exists │ │ └── .tflint.d │ │ │ └── policies │ │ │ ├── main.rego │ │ │ └── main_test.rego │ │ └── root-not-exists │ │ └── .gitkeep ├── test_rule.go ├── test_rule_test.go ├── test_runner.go └── test_runner_test.go └── tools └── release ├── go.mod ├── go.sum ├── main.go └── release-note.md /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: gomod 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | - package-ecosystem: "github-actions" 9 | directory: "/" 10 | schedule: 11 | interval: weekly 12 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test: 18 | name: ${{ matrix.os }} 19 | runs-on: ${{ matrix.os }} 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest, windows-latest] 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | - name: Set up Go 27 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 28 | with: 29 | go-version-file: 'go.mod' 30 | - name: Run tests 31 | run: make test 32 | - name: Run build 33 | run: make build 34 | -------------------------------------------------------------------------------- /.github/workflows/dependabot.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.event.pull_request.user.login == 'dependabot[bot]' 12 | steps: 13 | - name: Enable auto-merge for Dependabot PRs 14 | run: gh pr merge --auto --squash "$PR_URL" 15 | env: 16 | PR_URL: ${{github.event.pull_request.html_url}} 17 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 18 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | schedule: 11 | - cron: "0 0 * * *" 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | e2e: 18 | name: ${{ matrix.os }} (${{ matrix.version }}) 19 | runs-on: ${{ matrix.os }} 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest, windows-latest] 23 | version: [v0.43.0, latest] 24 | env: 25 | TFLINT_VERSION: ${{ matrix.version }} 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - name: Set up Go 30 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 31 | with: 32 | go-version-file: 'go.mod' 33 | - name: Install TFLint 34 | run: curl -sL https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | - name: Install plugin (Linux) 38 | if: runner.os == 'Linux' 39 | run: make install 40 | - name: Install plugin (Windows) 41 | if: runner.os == 'Windows' 42 | run: | 43 | mkdir -p ~/.tflint.d/plugins 44 | go build -o ~/.tflint.d/plugins/tflint-ruleset-opa.exe 45 | shell: bash 46 | - name: Run E2E tests 47 | run: make e2e 48 | -------------------------------------------------------------------------------- /.github/workflows/goreleaser.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | check: 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | with: 21 | fetch-depth: 0 22 | - name: Set up Go 23 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 24 | with: 25 | go-version-file: 'go.mod' 26 | - name: goreleaser check 27 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 28 | with: 29 | version: v2.7.0 30 | args: check 31 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - '!*' 7 | tags: 8 | - v*.*.* 9 | 10 | permissions: 11 | contents: write 12 | id-token: write 13 | attestations: write 14 | 15 | jobs: 16 | goreleaser: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | with: 22 | fetch-depth: 0 23 | - name: Set up Go 24 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 25 | with: 26 | go-version-file: 'go.mod' 27 | cache: true 28 | - name: Install Cosign 29 | uses: sigstore/cosign-installer@d7d6bc7722e3daa8354c50bcb52f4837da5e9b6a # v3.8.1 30 | - name: Run GoReleaser 31 | uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 32 | with: 33 | version: v2.7.0 34 | args: release --release-notes tools/release/release-note.md 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 37 | - uses: actions/attest-build-provenance@db473fddc028af60658334401dc6fa3ffd8669fd # v2.3.0 38 | with: 39 | subject-path: 'dist/checksums.txt' 40 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | # This is an example goreleaser.yaml file with some sane defaults. 2 | # Make sure to check the documentation at http://goreleaser.com 3 | version: 2 4 | env: 5 | - CGO_ENABLED=0 6 | builds: 7 | - targets: 8 | - darwin_amd64 9 | - darwin_arm64 10 | - linux_386 11 | - linux_amd64 12 | - linux_arm 13 | - linux_arm64 14 | - windows_386 15 | - windows_amd64 16 | hooks: 17 | post: 18 | - mkdir -p ./dist/raw 19 | - cp "{{ .Path }}" "./dist/raw/{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" 20 | archives: 21 | - id: zip 22 | name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" 23 | formats: 24 | - zip 25 | files: 26 | - none* 27 | checksum: 28 | name_template: 'checksums.txt' 29 | extra_files: 30 | - glob: ./dist/raw/* 31 | signs: 32 | - cmd: cosign 33 | signature: '${artifact}.keyless.sig' 34 | certificate: '${artifact}.pem' 35 | output: true 36 | artifacts: checksum 37 | args: 38 | - sign-blob 39 | - '--output-certificate=${certificate}' 40 | - '--output-signature=${signature}' 41 | - '${artifact}' 42 | - --yes 43 | release: 44 | github: 45 | owner: terraform-linters 46 | name: tflint-ruleset-opa 47 | draft: true 48 | snapshot: 49 | version_template: "{{ .Tag }}-dev" 50 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | See https://github.com/terraform-linters/tflint-ruleset-opa/releases for later releases. 2 | 3 | ## 0.7.0 (2024-05-05) 4 | 5 | ### Enhancements 6 | 7 | - [#92](https://github.com/terraform-linters/tflint-ruleset-opa/pull/92) [#95](https://github.com/terraform-linters/tflint-ruleset-opa/pull/95) [#98](https://github.com/terraform-linters/tflint-ruleset-opa/pull/98) [#102](https://github.com/terraform-linters/tflint-ruleset-opa/pull/102): Bump github.com/open-policy-agent/opa from 0.61.0 to 0.64.1 8 | - [#93](https://github.com/terraform-linters/tflint-ruleset-opa/pull/93) [#99](https://github.com/terraform-linters/tflint-ruleset-opa/pull/99): Bump github.com/hashicorp/hcl/v2 from 2.19.1 to 2.20.1 9 | - This is required for provider-defined functions support 10 | 11 | ### Chores 12 | 13 | - [#91](https://github.com/terraform-linters/tflint-ruleset-opa/pull/91) [#97](https://github.com/terraform-linters/tflint-ruleset-opa/pull/97): Bump github.com/zclconf/go-cty from 1.14.2 to 1.14.4 14 | - [#100](https://github.com/terraform-linters/tflint-ruleset-opa/pull/100): Bump github.com/hashicorp/go-hclog from 1.6.2 to 1.6.3 15 | - [#101](https://github.com/terraform-linters/tflint-ruleset-opa/pull/101): Bump golang.org/x/net from 0.22.0 to 0.23.0 16 | - [#103](https://github.com/terraform-linters/tflint-ruleset-opa/pull/103): deps: Go 1.22.2 17 | - [#104](https://github.com/terraform-linters/tflint-ruleset-opa/pull/104): Bump github.com/terraform-linters/tflint-plugin-sdk from 0.18.0 to 0.20.0 18 | 19 | ## 0.6.0 (2024-02-23) 20 | 21 | ### Enhancements 22 | 23 | - [#83](https://github.com/terraform-linters/tflint-ruleset-opa/pull/83): Bump github.com/open-policy-agent/opa from 0.60.0 to 0.61.0 24 | - [#87](https://github.com/terraform-linters/tflint-ruleset-opa/pull/87): Add `terraform.removed_blocks` function 25 | 26 | ### Chores 27 | 28 | - [#82](https://github.com/terraform-linters/tflint-ruleset-opa/pull/82): Bump github.com/zclconf/go-cty from 1.14.1 to 1.14.2 29 | - [#86](https://github.com/terraform-linters/tflint-ruleset-opa/pull/86): deps: Go 1.22 30 | - [#88](https://github.com/terraform-linters/tflint-ruleset-opa/pull/88): Rewrite policies with `import rego.v1` 31 | 32 | ## 0.5.0 (2023-12-27) 33 | 34 | ### Enhancements 35 | 36 | - [#67](https://github.com/terraform-linters/tflint-ruleset-opa/pull/67): Add support for scoped data sources 37 | - [#69](https://github.com/terraform-linters/tflint-ruleset-opa/pull/69): Add `terraform.imports` and `terraform.checks` functions 38 | - [#71](https://github.com/terraform-linters/tflint-ruleset-opa/pull/71) [#74](https://github.com/terraform-linters/tflint-ruleset-opa/pull/74) [#75](https://github.com/terraform-linters/tflint-ruleset-opa/pull/75) [#79](https://github.com/terraform-linters/tflint-ruleset-opa/pull/79): Bump github.com/open-policy-agent/opa from 0.57.0 to 0.60.0 39 | 40 | ### Chores 41 | 42 | - [#64](https://github.com/terraform-linters/tflint-ruleset-opa/pull/64) [#72](https://github.com/terraform-linters/tflint-ruleset-opa/pull/72): Bump github.com/hashicorp/hcl/v2 from 2.18.0 to 2.19.1 43 | - [#65](https://github.com/terraform-linters/tflint-ruleset-opa/pull/65): Bump github.com/zclconf/go-cty from 1.14.0 to 1.14.1 44 | - [#66](https://github.com/terraform-linters/tflint-ruleset-opa/pull/66): Bump golang.org/x/net from 0.15.0 to 0.17.0 45 | - [#68](https://github.com/terraform-linters/tflint-ruleset-opa/pull/68): Fix incorrect examples of `terraform.resources` 46 | - [#70](https://github.com/terraform-linters/tflint-ruleset-opa/pull/70): Bump github.com/google/go-cmp from 0.5.9 to 0.6.0 47 | - [#73](https://github.com/terraform-linters/tflint-ruleset-opa/pull/73): Bump google.golang.org/grpc from 1.58.2 to 1.58.3 48 | - [#76](https://github.com/terraform-linters/tflint-ruleset-opa/pull/76): Bump actions/setup-go from 4 to 5 49 | - [#77](https://github.com/terraform-linters/tflint-ruleset-opa/pull/77) [#78](https://github.com/terraform-linters/tflint-ruleset-opa/pull/78): Bump github.com/hashicorp/go-hclog from 1.5.0 to 1.6.2 50 | - [#80](https://github.com/terraform-linters/tflint-ruleset-opa/pull/80): Fix E2E tests failing with TFLint v0.50 51 | 52 | ## 0.4.0 (2023-10-09) 53 | 54 | ### Enhancements 55 | 56 | - [#53](https://github.com/terraform-linters/tflint-ruleset-opa/pull/53) [#59](https://github.com/terraform-linters/tflint-ruleset-opa/pull/59) [#63](https://github.com/terraform-linters/tflint-ruleset-opa/pull/63): Bump github.com/open-policy-agent/opa from 0.54.0 to 0.57.0 57 | 58 | ### Chores 59 | 60 | - [#54](https://github.com/terraform-linters/tflint-ruleset-opa/pull/54): Bump github.com/terraform-linters/tflint-plugin-sdk from 0.17.0 to 0.18.0 61 | - [#55](https://github.com/terraform-linters/tflint-ruleset-opa/pull/55): Add raw binary entries to checksums.txt 62 | - [#56](https://github.com/terraform-linters/tflint-ruleset-opa/pull/56) [#58](https://github.com/terraform-linters/tflint-ruleset-opa/pull/58): Bump github.com/zclconf/go-cty from 1.13.2 to 1.14.0 63 | - [#57](https://github.com/terraform-linters/tflint-ruleset-opa/pull/57): Bump actions/checkout from 3 to 4 64 | - [#60](https://github.com/terraform-linters/tflint-ruleset-opa/pull/60): Bump github.com/hashicorp/hcl/v2 from 2.17.0 to 2.18.0 65 | - [#61](https://github.com/terraform-linters/tflint-ruleset-opa/pull/61): deps: Go 1.21 66 | - [#62](https://github.com/terraform-linters/tflint-ruleset-opa/pull/62): Bump goreleaser/goreleaser-action from 4 to 5 67 | 68 | ## 0.3.0 (2023-07-19) 69 | 70 | ### Enhancements 71 | 72 | - [#42](https://github.com/terraform-linters/tflint-ruleset-opa/pull/42) [#51](https://github.com/terraform-linters/tflint-ruleset-opa/pull/51): Bump github.com/terraform-linters/tflint-plugin-sdk from 0.16.0 to 0.17.0 73 | - [#45](https://github.com/terraform-linters/tflint-ruleset-opa/pull/45) [#47](https://github.com/terraform-linters/tflint-ruleset-opa/pull/47) [#49](https://github.com/terraform-linters/tflint-ruleset-opa/pull/49) [#52](https://github.com/terraform-linters/tflint-ruleset-opa/pull/52): Bump github.com/open-policy-agent/opa from 0.51.0 to 0.54.0 74 | 75 | ### Chores 76 | 77 | - [#44](https://github.com/terraform-linters/tflint-ruleset-opa/pull/44): docs: Clarify what `policy_dir` is relative to 78 | - [#46](https://github.com/terraform-linters/tflint-ruleset-opa/pull/46): Bump github.com/zclconf/go-cty from 1.13.1 to 1.13.2 79 | - [#48](https://github.com/terraform-linters/tflint-ruleset-opa/pull/48): Bump github.com/hashicorp/hcl/v2 from 2.16.2 to 2.17.0 80 | 81 | ## 0.2.0 (2023-04-10) 82 | 83 | ### Enhancements 84 | 85 | - [#26](https://github.com/terraform-linters/tflint-ruleset-opa/pull/26) [#29](https://github.com/terraform-linters/tflint-ruleset-opa/pull/29) [#32](https://github.com/terraform-linters/tflint-ruleset-opa/pull/32) [#33](https://github.com/terraform-linters/tflint-ruleset-opa/pull/33) [#37](https://github.com/terraform-linters/tflint-ruleset-opa/pull/37) [#39](https://github.com/terraform-linters/tflint-ruleset-opa/pull/39): Bump github.com/open-policy-agent/opa from 0.48.0 to 0.51.0 86 | 87 | ### BugFixes 88 | 89 | - [#40](https://github.com/terraform-linters/tflint-ruleset-opa/pull/40): Fix internal marshal error of sensitive value 90 | 91 | ### Chores 92 | 93 | - [#24](https://github.com/terraform-linters/tflint-ruleset-opa/pull/24) [#25](https://github.com/terraform-linters/tflint-ruleset-opa/pull/25) [#31](https://github.com/terraform-linters/tflint-ruleset-opa/pull/31): Bump github.com/hashicorp/hcl/v2 from 2.15.0 to 2.16.2 94 | - [#27](https://github.com/terraform-linters/tflint-ruleset-opa/pull/27): Bump golang.org/x/net from 0.5.0 to 0.7.0 95 | - [#28](https://github.com/terraform-linters/tflint-ruleset-opa/pull/28) [#35](https://github.com/terraform-linters/tflint-ruleset-opa/pull/35): Bump github.com/zclconf/go-cty from 1.12.1 to 1.13.1 96 | - [#30](https://github.com/terraform-linters/tflint-ruleset-opa/pull/30): Bump sigstore/cosign-installer from 2 to 3 97 | - [#34](https://github.com/terraform-linters/tflint-ruleset-opa/pull/34): Bump actions/setup-go from 3 to 4 98 | - [#36](https://github.com/terraform-linters/tflint-ruleset-opa/pull/36): Bump github.com/hashicorp/go-hclog from 1.4.0 to 1.5.0 99 | - [#38](https://github.com/terraform-linters/tflint-ruleset-opa/pull/38): Bump github.com/terraform-linters/tflint-plugin-sdk from 0.15.0 to 0.16.0 100 | - [#41](https://github.com/terraform-linters/tflint-ruleset-opa/pull/41): deps: Go 1.20 101 | 102 | ## 0.1.0 (2023-02-02) 103 | 104 | Initial release 🎉 105 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: build 2 | 3 | test: 4 | go test $$(go list ./... | grep -v integration) 5 | 6 | e2e: 7 | go test ./integration 8 | 9 | build: 10 | go build 11 | 12 | install: build 13 | mkdir -p ~/.tflint.d/plugins 14 | mv ./tflint-ruleset-opa ~/.tflint.d/plugins 15 | 16 | release: 17 | cd tools/release; go run main.go 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TFLint Ruleset powered by Open Policy Agent (OPA) 2 | [![Build Status](https://github.com/terraform-linters/tflint-ruleset-opa/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/terraform-linters/tflint-ruleset-opa/actions) 3 | [![GitHub release](https://img.shields.io/github/release/terraform-linters/tflint-ruleset-opa.svg)](https://github.com/terraform-linters/tflint-ruleset-opa/releases/latest) 4 | [![License: MPL 2.0](https://img.shields.io/badge/License-MPL%202.0-blue.svg)](LICENSE) 5 | 6 | TFLint ruleset plugin for writing custom rules in [Rego](https://www.openpolicyagent.org/docs/latest/policy-language/). 7 | 8 | NOTE: This plugin is experimental. This means frequent breaking changes. 9 | 10 | ## Requirements 11 | 12 | - TFLint v0.43+ 13 | - Go v1.24 14 | 15 | ## Installation 16 | 17 | You can install the plugin by adding a config to `.tflint.hcl` and running `tflint --init`: 18 | 19 | ```hcl 20 | plugin "opa" { 21 | enabled = true 22 | version = "0.8.0" 23 | source = "github.com/terraform-linters/tflint-ruleset-opa" 24 | } 25 | ``` 26 | 27 | Policy files are placed under `~/.tflint.d/policies` or `./.tflint.d/policies`. First create a directory: 28 | 29 | ```console 30 | $ mkdir -p .tflint.d/policies 31 | ``` 32 | 33 | For more configuration about the plugin, see [Plugin Configuration](./docs/configuration.md). 34 | 35 | ## Getting Started 36 | 37 | TFLint plugin system allows you to add custom rules, but plugins can be a pain to maintain when applying a few simple organization policies. This ruleset plugin provides the ability to write policies in Rego, instead of building plugins in Go. 38 | 39 | For example, your organization wants to enforce S3 bucket names to always start with `example-com-*`. You can write the following policy as `./.tflint.d/policies/bucket.rego`: 40 | 41 | ```rego 42 | package tflint 43 | 44 | import rego.v1 45 | 46 | deny_invalid_s3_bucket_name contains issue if { 47 | buckets := terraform.resources("aws_s3_bucket", {"bucket": "string"}, {}) 48 | name := buckets[_].config.bucket 49 | not startswith(name.value, "example-com-") 50 | 51 | issue := tflint.issue(`Bucket names should always start with "example-com-"`, name.range) 52 | } 53 | ``` 54 | 55 | This allows you to issue errors for Terraform configs such as: 56 | 57 | ```hcl 58 | resource "aws_s3_bucket" "invalid" { 59 | bucket = "example-corp-assets" 60 | } 61 | 62 | resource "aws_s3_bucket" "valid" { 63 | bucket = "example-com-assets" 64 | } 65 | ``` 66 | 67 | ```console 68 | $ tflint 69 | 1 issue(s) found: 70 | 71 | Error: Bucket names should always start with "example-com-" (opa_deny_invalid_s3_bucket_name) 72 | 73 | on main.tf line 2: 74 | 2: bucket = "example-corp-assets" 75 | 76 | Reference: .tflint.d/policies/bucket.rego:5 77 | 78 | ``` 79 | 80 | See [the documentation](./docs/) and [examples](./examples/) for details. 81 | 82 | NOTE: This policy cannot be enforced in all cases. See [Handling unknown/null/undefined values](./docs/handling_special_values.md) for details. 83 | 84 | ## OPA Ruleset vs. Custom Ruleset 85 | 86 | There are two options for providing custom rules: the OPA ruleset and a custom ruleset. Which is better? 87 | 88 | If you want to enforce a small number of rules for a small team, or if your don't have a dedicated team to maintain your plugin, starting with the OPA ruleset is probably a good option. 89 | 90 | On the other hand, building and maintaining a custom ruleset plugin is better when enforcing many complex rules or distributing to large teams. 91 | 92 | ## Building the plugin 93 | 94 | Clone the repository locally and run the following command: 95 | 96 | ``` 97 | $ make 98 | ``` 99 | 100 | You can easily install the built plugin with the following: 101 | 102 | ``` 103 | $ make install 104 | ``` 105 | 106 | You can run the built plugin like the following: 107 | 108 | ``` 109 | $ cat << EOS > .tflint.hcl 110 | plugin "opa" { 111 | enabled = true 112 | } 113 | EOS 114 | $ tflint 115 | ``` 116 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | # TFLint OPA Ruleset Documentation 2 | 3 | - [Introduction](./intro.md) 4 | - [Terraform Schema](./schema.md) 5 | - [Functions](./functions.md) 6 | - [Debugging](./debug.md) 7 | - [Testing](./testing.md) 8 | - [Handling unknown/null/undefined values](./handling_special_values.md) 9 | - [Configuration](./configuration.md) 10 | - [Environment Variables](./environment_variables.md) 11 | - [TFLint OPA Ruleset vs. OPA/Conftest/Sentinel](./alternatives.md) 12 | -------------------------------------------------------------------------------- /docs/alternatives.md: -------------------------------------------------------------------------------- 1 | # TFLint OPA Ruleset vs. OPA/Conftest/Sentinel 2 | 3 | Besides this ruleset, there are other solutions for Policy as Code. This document compares them and provides information to help you decide which solution to adopt. 4 | 5 | ## TFLint OPA Ruleset vs. OPA 6 | 7 | OPA officially publishes [an example of applying a policy to Terraform](https://www.openpolicyagent.org/docs/latest/terraform/). 8 | 9 | This way is reliable and stable as it depends only on Terraform's plan file structure and OPA. If you are already satisfied with this way, there may be little benefit to adopting the TFLint OPA ruleset. 10 | 11 | On the other hand, the advantage of the TFLint OPA ruleset is that you don't need to run `terraform plan` to apply policies. So you can write policies against all files, not just diffs, and quickly check if your code violates the policies. 12 | 13 | ## TFLint OPA Ruleset vs. Conftest 14 | 15 | [Conftest](https://www.conftest.dev/) is a popular solution developed under the Open Policy Agent organization. 16 | 17 | Conftest has native support for HCL and supports many other formats such as Dockerfile and YAML. This is a great option if you want to enforce policies with the same tool for many other configs. 18 | 19 | However, Conftest does not support semantics such as variables in HCL. If you want to write a policy against an evaluated configuration, you need to write the policy against a plan file. 20 | 21 | ## TFLint OPA Ruleset vs. Sentinel 22 | 23 | [Sentinel](https://www.hashicorp.com/sentinel) is a Policy as Code solution developed by HashiCorp. 24 | 25 | Sentinel is a commercial product and works seamlessly with Terraform and other HashiCorp products. If policy enforcement is important to your organization, Sentinel may be a good option. 26 | 27 | On the other hand, TFLint OPA Ruleset is a good option to start if policy enforcement is not yet important or if commercial product is not available. 28 | -------------------------------------------------------------------------------- /docs/configuration.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | This plugin can take advantage of additional features by configure the plugin block. Currently, this configuration is only available for customizing a policy directory. 4 | 5 | Here's an example: 6 | 7 | ```hcl 8 | plugin "opa" { 9 | // Plugin common attributes 10 | 11 | policy_dir = "./policies" 12 | } 13 | ``` 14 | 15 | ## `policy_dir` 16 | 17 | Default: `./.tflint.d/policies`, `~/.tflint.d/policies` 18 | 19 | Change the directory from which policies are loaded. The priority is as follows: 20 | 21 | 1. `policy_dir` in the config 22 | 2. `TFLINT_OPA_POLICY_DIR` environment variable 23 | 3. `./.tflint.d/policies` 24 | 4. `~/.tflint.d/policies` 25 | 26 | A relative path is resolved from the current directory. 27 | -------------------------------------------------------------------------------- /docs/debug.md: -------------------------------------------------------------------------------- 1 | # Debugging 2 | 3 | If your policy doesn't work as intended, you can use the debugging functions provided by OPA to help troubleshoot. 4 | 5 | ## `print` 6 | 7 | The `print` function can output arbitrary values to the log. You can check the value by setting `TFLINT_LOG=debug`. 8 | 9 | ```hcl 10 | resource "aws_instance" "main" { 11 | instance_type = "t2.micro" 12 | } 13 | ``` 14 | 15 | ```rego 16 | package tflint 17 | 18 | import rego.v1 19 | 20 | deny_invalid_instance_type contains issue if { 21 | instances := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 22 | print(instances) 23 | instances[_].config.type.value == "t2.micro" # typo: type -> instance_type 24 | 25 | issue := tflint.issue("t2.micro is not allowed", instances[_].config.instance_type.range) 26 | } 27 | ``` 28 | 29 | ```console 30 | $ TFLINT_LOG=debug tflint 31 | ... 32 | 16:47:48 [DEBUG] host2plugin/client.go:124: starting host-side gRPC server 33 | 16:47:48 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:47:48 [DEBUG] topdown/print.go:48: [{"config": {"instance_type": {"range": {"end": {"byte": 61, "column": 29, "line": 2}, "filename": "main.tf", "start": {"byte": 51, "column": 19, "line": 2}}, "sensitive": false, "ephemeral": false, "unknown": false, "value": "t2.micro"}}, "decl_range": {"end": {"byte": 30, "column": 31, "line": 1}, "filename": "main.tf", "start": {"byte": 0, "column": 1, "line": 1}}, "name": "main", "type": "aws_instance"}] 34 | ... 35 | ``` 36 | 37 | ## `trace` 38 | 39 | The `trace` function prints a `note` to the trace. Tracing can be enabled by setting `TFLINT_OPA_TRACE=1`. Traces are printed to the log. 40 | 41 | ```rego 42 | package tflint 43 | 44 | import rego.v1 45 | 46 | deny_invalid_instance_type contains issue if { 47 | instances := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 48 | trace("after fetch") 49 | instances[_].config.type.value == "t2.micro" 50 | 51 | issue := tflint.issue("t2.micro is not allowed", instances[_].config.instance_type.range) 52 | } 53 | ``` 54 | 55 | ```console 56 | $ TFLINT_LOG=debug TFLINT_OPA_TRACE=1 tflint 57 | ... 58 | 16:55:12 [DEBUG] host2plugin/client.go:124: starting host-side gRPC server 59 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: Enter data.tflint.deny_invalid_instance_type = _ 60 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | Eval data.tflint.deny_invalid_instance_type = _ 61 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | Unify data.tflint.deny_invalid_instance_type = _ 62 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | Index data.tflint.deny_invalid_instance_type (matched 1 rule) 63 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | Enter data.tflint.deny_invalid_instance_type 64 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Eval terraform.resources("aws_instance", {"instance_type": "string"}, {}, __local2__) 65 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Unify __local2__ = [{"config": {"instance_type": {"range": {"end": {"byte": 61, "column": 29, "line": 2}, "filename": "main.tf", "start": {"byte": 51, "column": 19, "line": 2}}, "sensitive": false, "ephemeral": false, "unknown": false, "value": "t2.micro"}}, "decl_range": {"end": {"byte": 30, "column": 31, "line": 1}, "filename": "main.tf", "start": {"byte": 0, "column": 1, "line": 1}}, "name": "main", "type": "aws_instance"}] 66 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Eval instances = __local2__ 67 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Unify instances = [{"config": {"instance_type": {"range": {"end": {"byte": 61, "column": 29, "line": 2}, "filename": "main.tf", "start": {"byte": 51, "column": 19, "line": 2}}, "sensitive": false, "ephemeral": false, "unknown": false, "value": "t2.micro"}}, "decl_range": {"end": {"byte": 30, "column": 31, "line": 1}, "filename": "main.tf", "start": {"byte": 0, "column": 1, "line": 1}}, "name": "main", "type": "aws_instance"}] 68 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Eval trace("after fetch") 69 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Note "after fetch" 70 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Eval instances[_].config.type.value = "t2.micro" 71 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Unify instances[_].config.type.value = "t2.micro" 72 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Unify 0 = _ 73 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Fail instances[_].config.type.value = "t2.micro" 74 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Redo trace("after fetch") 75 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Redo instances = __local2__ 76 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | | Redo terraform.resources("aws_instance", {"instance_type": "string"}, {}, __local2__) 77 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | Unify set() = _ 78 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | Exit data.tflint.deny_invalid_instance_type = _ 79 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: Redo data.tflint.deny_invalid_instance_type = _ 80 | 16:55:12 [DEBUG] go-plugin@v1.4.8/client.go:1045: tflint-ruleset-opa: 16:55:12 [DEBUG] topdown/trace.go:239: | Redo data.tflint.deny_invalid_instance_type = _ 81 | ... 82 | ``` 83 | -------------------------------------------------------------------------------- /docs/environment_variables.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | Below is a list of environment variables that have meaning in the OPA ruleset: 4 | 5 | - `TFLINT_OPA_POLICY_DIR` 6 | - Directory where policy files are placed. See [Configuration](./configuration.md). 7 | - `TFLINT_OPA_TRACE` 8 | - Enable tracing. See [Debugging](./debug.md). 9 | - `TFLINT_OPA_TEST` 10 | - Enable test mode. See [Testing](./testing.md) 11 | -------------------------------------------------------------------------------- /docs/intro.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | This plugin is backed by [Open Policy Agent (OPA)](https://www.openpolicyagent.org/docs/latest/) and allows you to write custom rules for TFLint in the policy language (Rego). This document will guide you through the step-by-step process of getting started writing policies. 4 | 5 | First, refer to the official documentation for [OPA concepts](https://www.openpolicyagent.org/docs/latest/) and [Policy Language](https://www.openpolicyagent.org/docs/latest/policy-language/). The documentation that follows assumes familiarity with these concepts. 6 | 7 | As an example, create the following policy as `.tflint.d/policies/bucket.rego`: 8 | 9 | ```rego 10 | package tflint 11 | 12 | import rego.v1 13 | 14 | deny_invalid_s3_bucket_name contains issue if { 15 | buckets := terraform.resources("aws_s3_bucket", {"bucket": "string"}, {}) 16 | name := buckets[_].config.bucket 17 | not startswith(name.value, "example-com-") 18 | 19 | issue := tflint.issue(`Bucket names should always start with "example-com-"`, name.range) 20 | } 21 | ``` 22 | 23 | Suppose you apply a policy to the following files: 24 | 25 | ```hcl 26 | resource "aws_s3_bucket" "invalid" { 27 | bucket = "example-corp-assets" 28 | } 29 | 30 | resource "aws_s3_bucket" "valid" { 31 | bucket = "example-com-assets" 32 | } 33 | ``` 34 | 35 | Let's go through it line by line. 36 | 37 | ```rego 38 | package tflint 39 | ``` 40 | 41 | The first line is the package declaration. All valid policies must be described under the `tflint` package. 42 | 43 | ```rego 44 | import rego.v1 45 | ``` 46 | 47 | This declaration ensures compatibility with future OPA v1 syntax. See [The `rego.v1` Import](https://www.openpolicyagent.org/docs/latest/policy-language/#the-regov1-import) for details. 48 | 49 | ```rego 50 | deny_invalid_s3_bucket_name contains issue if { 51 | ``` 52 | 53 | The next line is the rule declaration. A valid rule name must start with `deny_`, `violation_`, `warn_` or `notice_`. The rule name in TFLint is the rule name with "opa_" prefix (e.g. `opa_deny_invalid_s3_bucket_name`), and the severity is error for `deny_` or `violation_`, warning for `warn_`, and notice for `notice_`. 54 | 55 | The rule should return a set of issue objects, not a boolean. An issue is created on the last line when all conditions are met. 56 | 57 | ```rego 58 | buckets := terraform.resources("aws_s3_bucket", {"bucket": "string"}, {}) 59 | ``` 60 | 61 | The next line is to retrieve `aws_s3_bucket` resources. The policy language written in this plugin primarily uses JSON retrieved by custom functions rather than input data. The `terraform.resources` is a custom function to retrieve `resource` blocks in Terraform configs. For more custom functions, see [Functions](./functions.md). 62 | 63 | Note that the schema must be declared when referencing inside a resource block. The example above declares that the `bucket` attribute exists as a string. See [Terraform Schema](./schema.md) for details. 64 | 65 | The return value of this function will be the following JSON: 66 | 67 | ```json 68 | [ 69 | { 70 | "type": "aws_s3_bucket", 71 | "name": "invalid", 72 | "config": { 73 | "bucket": { 74 | "value": "example-corp-assets", 75 | "unknown": false, 76 | "sensitive": false, 77 | "ephemeral": false, 78 | "range": { 79 | "filename": "main.tf", 80 | "start": { "line": 2, "column": 12, "byte": 48 }, 81 | "end": { "line": 2, "column": 33, "byte": 69 } 82 | } 83 | } 84 | }, 85 | "decl_range": { 86 | "filename": "main.tf", 87 | "start": { "line": 1, "column": 1, "byte": 0 }, 88 | "end": { "line": 1, "column": 35, "byte": 34 } 89 | } 90 | }, 91 | { 92 | "type": "aws_s3_bucket", 93 | "name": "valid", 94 | "config": { 95 | "bucket": { 96 | "value": "example-com-assets", 97 | "unknown": false, 98 | "sensitive": false, 99 | "ephemeral": false, 100 | "range": { 101 | "filename": "main.tf", 102 | "start": { "line": 6, "column": 12, "byte": 119 }, 103 | "end": { "line": 6, "column": 32, "byte": 139 } 104 | } 105 | } 106 | }, 107 | "decl_range": { 108 | "filename": "main.tf", 109 | "start": { "line": 5, "column": 1, "byte": 73 }, 110 | "end": { "line": 5, "column": 33, "byte": 105 } 111 | } 112 | } 113 | ] 114 | ``` 115 | 116 | Attributes set in the schema are included under the `config` if they actually exist. 117 | 118 | ```rego 119 | name := buckets[_].config.bucket 120 | not startswith(name.value, "example-com-") 121 | ``` 122 | 123 | The next line is to get the `bucket` attributes. Note that the value is `bucket.value` and `bucket` is an object. 124 | 125 | ```rego 126 | issue := tflint.issue(`Bucket names should always start with "example-com-"`, name.range) 127 | ``` 128 | 129 | The last line is to generate an issue. Use the `tflint.issue` function to specify the message and range. 130 | 131 | This allows you to raise an issue for config that violates your policy: 132 | 133 | ```console 134 | $ tflint 135 | 1 issue(s) found: 136 | 137 | Error: Bucket names should always start with "example-com-" (opa_deny_invalid_s3_bucket_name) 138 | 139 | on main.tf line 2: 140 | 2: bucket = "example-corp-assets" 141 | 142 | Reference: .tflint.d/policies/main.rego:5 143 | 144 | ``` 145 | 146 | Note that this policy cannot enforce policy in all cases. See [Handling unknown/null/undefined values](./handling_special_values.md) for details. 147 | -------------------------------------------------------------------------------- /docs/schema.md: -------------------------------------------------------------------------------- 1 | # Terraform Schema 2 | 3 | Some functions take a Terraform schema as an argument. This document describes the details of the schema. 4 | 5 | Schema is an object that defines an internal body structure. TFLint decodes the body based on the schema, so the schema is always required to access attributes. Values not set in the schema are not included in the return value. 6 | 7 | For example, a schema required to decode the top-level `instance_type` is: 8 | 9 | ```rego 10 | {"instance_type": "string"} 11 | ``` 12 | 13 | The object's key is the attribute name and the value represents the type. The type syntax is the same as [Terraform's type constraints](https://developer.hashicorp.com/terraform/language/expressions/type-constraints). 14 | 15 | TFLint implicitly converts values according to their type, which is useful when working with numbers. 16 | 17 | ```hcl 18 | resource "aws_instance" "number" { 19 | ebs_block_device { 20 | volume_size = 50 21 | } 22 | } 23 | 24 | resource "aws_instance" "string" { 25 | ebs_block_device { 26 | volume_size = "50" # => convert to number in JSON 27 | } 28 | } 29 | ``` 30 | 31 | If you don't know the attribute type, you can use `any`. In this case no conversion is done, but the raw value from the config file is available. 32 | 33 | A schema for decoding nested blocks is: 34 | 35 | ```rego 36 | {"ebs_block_device": {"volume_size": "string"}} 37 | ``` 38 | 39 | You can use objects instead of types as values. The objects represent nested schemas. 40 | 41 | A more special case is the labeled block schema. For example, here is a schema available to retrieve a dynamic block like: 42 | 43 | ```hcl 44 | resource "aws_instance" "main" { 45 | dynamic "ebs_block_device" { 46 | for_each = var.devices 47 | } 48 | } 49 | ``` 50 | 51 | ```rego 52 | {"dynamic": {"__labels": ["type"], "for_each": "any"}} 53 | ``` 54 | 55 | The `__labels` is a special key that sets labels. The value defines the label name in an array, not the type. Label names are basically meaningless. 56 | -------------------------------------------------------------------------------- /docs/testing.md: -------------------------------------------------------------------------------- 1 | # Testing 2 | 3 | You can write tests to continue to ensure that complex policies work as intended. See also [Policy Testing](https://www.openpolicyagent.org/docs/latest/policy-testing/). 4 | 5 | Below is an example that tests a policy that enforces a bucket name: 6 | 7 | ```rego 8 | package tflint 9 | 10 | import rego.v1 11 | 12 | deny_invalid_s3_bucket_name contains issue if { 13 | buckets := terraform.resources("aws_s3_bucket", {"bucket": "string"}, {}) 14 | name := buckets[_].config.bucket 15 | not startswith(name.value, "example-com-") 16 | 17 | issue := tflint.issue(`Bucket names should always start with "example-com-"`, name.range) 18 | } 19 | ``` 20 | 21 | ```rego 22 | package tflint 23 | 24 | import rego.v1 25 | 26 | failed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 27 | resource "aws_s3_bucket" "invalid" { 28 | bucket = "example-corp-assets" 29 | }`}) 30 | 31 | test_deny_invalid_s3_bucket_name_failed if { 32 | issues := deny_invalid_s3_bucket_name with terraform.resources as failed_resources 33 | 34 | count(issues) == 1 35 | issue := issues[_] 36 | issue.msg == `Bucket names should always start with "example-com-"` 37 | } 38 | 39 | passed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 40 | resource "aws_s3_bucket" "valid" { 41 | bucket = "example-com-assets" 42 | }`}) 43 | 44 | test_deny_invalid_s3_bucket_name_passed if { 45 | issues := deny_invalid_s3_bucket_name with terraform.resources as passed_resources 46 | 47 | count(issues) == 0 48 | } 49 | ``` 50 | 51 | Functions can be mocked with `terraform.mock_*` functions. Define a new function with the HCL file as the last argument and use `with` to replace the function. 52 | 53 | You can run tests by setting `TFLINT_OPA_TEST=1`: 54 | 55 | ```console 56 | # Passed 57 | $ TFLINT_OPA_TEST=1 tflint 58 | // No output 59 | 60 | # Failed 61 | $ TFLINT_OPA_TEST=1 tflint 62 | 1 issue(s) found: 63 | 64 | Error: test failed (opa_test_deny_invalid_s3_bucket_name_failed) 65 | 66 | on line 0: 67 | (source code not available) 68 | 69 | Reference: .tflint.d/policies/bucket_test.rego:10 70 | 71 | ``` 72 | -------------------------------------------------------------------------------- /examples/enforce_bucket_names/.tflint.d/policies/bucket.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | # Set `expand_mode: none` to check names even if they are not created 6 | s3_buckets := terraform.resources("aws_s3_bucket", {"bucket": "string"}, {"expand_mode": "none"}) 7 | 8 | s3_bucket_names contains name if { 9 | name := s3_buckets[_].config.bucket 10 | } 11 | 12 | # Rules for unknown values 13 | deny_invalid_s3_bucket_name contains issue if { 14 | s3_bucket_names[i].unknown 15 | 16 | issue := tflint.issue("Dynamic value is not allowed in bucket", s3_bucket_names[i].range) 17 | } 18 | 19 | # Rules for invalid names 20 | deny_invalid_s3_bucket_name contains issue if { 21 | not startswith(s3_bucket_names[i].value, "example-com-") 22 | 23 | issue := tflint.issue(`Bucket names should always start with "example-com-"`, s3_bucket_names[i].range) 24 | } 25 | -------------------------------------------------------------------------------- /examples/enforce_bucket_names/.tflint.d/policies/bucket_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | failed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_s3_bucket" "invalid" { 7 | bucket = "example-corp-assets" 8 | }`}) 9 | 10 | test_deny_invalid_s3_bucket_name_failed if { 11 | issues := deny_invalid_s3_bucket_name with terraform.resources as failed_resources 12 | 13 | count(issues) == 1 14 | issue := issues[_] 15 | issue.msg == `Bucket names should always start with "example-com-"` 16 | } 17 | 18 | passed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 19 | resource "aws_s3_bucket" "valid" { 20 | bucket = "example-com-assets" 21 | }`}) 22 | 23 | test_deny_invalid_s3_bucket_name_passed if { 24 | issues := deny_invalid_s3_bucket_name with terraform.resources as passed_resources 25 | 26 | count(issues) == 0 27 | } 28 | 29 | unknown_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 30 | variable "unknown" {} 31 | 32 | resource "aws_s3_bucket" "invalid" { 33 | bucket = var.unknown 34 | }`}) 35 | 36 | test_deny_invalid_s3_bucket_name_unknown if { 37 | issues := deny_invalid_s3_bucket_name with terraform.resources as unknown_resources 38 | 39 | count(issues) == 1 40 | issue := issues[_] 41 | issue.msg == "Dynamic value is not allowed in bucket" 42 | } 43 | 44 | unknown_count_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 45 | variable "unknown" {} 46 | 47 | resource "aws_s3_bucket" "invalid" { 48 | count = var.unknown 49 | bucket = "example-corp-assets" 50 | }`}) 51 | 52 | test_deny_invalid_s3_bucket_name_unknown_count if { 53 | issues := deny_invalid_s3_bucket_name with terraform.resources as failed_resources 54 | 55 | count(issues) == 1 56 | issue := issues[_] 57 | issue.msg == `Bucket names should always start with "example-com-"` 58 | } 59 | 60 | unknown_for_each_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 61 | variable "unknown" {} 62 | 63 | resource "aws_s3_bucket" "invalid" { 64 | for_each = var.unknown 65 | bucket = "example-corp-assets" 66 | }`}) 67 | 68 | test_deny_invalid_s3_bucket_name_unknown_for_each if { 69 | issues := deny_invalid_s3_bucket_name with terraform.resources as failed_resources 70 | 71 | count(issues) == 1 72 | issue := issues[_] 73 | issue.msg == `Bucket names should always start with "example-com-"` 74 | } 75 | -------------------------------------------------------------------------------- /examples/enforce_bucket_names/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "opa" { 2 | enabled = true 3 | } 4 | 5 | plugin "terraform" { 6 | enabled = false 7 | } 8 | -------------------------------------------------------------------------------- /examples/enforce_bucket_names/README.md: -------------------------------------------------------------------------------- 1 | # Enforce bucket names 2 | 3 | This is an example of applying naming conventions to top-level attributes. 4 | 5 | ## Requirements 6 | 7 | - Disallow S3 bucket names starting with anything other than "example-com-". 8 | - Disallow unknown bucket name. 9 | - Always warn even if the bucket is not created. 10 | - Ignore if bucket name is not set. 11 | 12 | ## Results 13 | 14 | ```console 15 | $ tflint 16 | 4 issue(s) found: 17 | 18 | Error: Bucket names should always start with "example-com-" (opa_deny_invalid_s3_bucket_name) 19 | 20 | on main.tf line 2: 21 | 2: bucket = "example-corp-assets" 22 | 23 | Reference: .tflint.d/policies/bucket.rego:13 24 | 25 | Error: Dynamic value is not allowed in bucket (opa_deny_invalid_s3_bucket_name) 26 | 27 | on main.tf line 12: 28 | 12: bucket = var.unknown 29 | 30 | Reference: .tflint.d/policies/bucket.rego:13 31 | 32 | Error: Bucket names should always start with "example-com-" (opa_deny_invalid_s3_bucket_name) 33 | 34 | on main.tf line 18: 35 | 18: bucket = "example-corp-assets" 36 | 37 | Reference: .tflint.d/policies/bucket.rego:13 38 | 39 | Error: Bucket names should always start with "example-com-" (opa_deny_invalid_s3_bucket_name) 40 | 41 | on main.tf line 24: 42 | 24: bucket = "example-corp-assets" 43 | 44 | Reference: .tflint.d/policies/bucket.rego:13 45 | 46 | ``` 47 | -------------------------------------------------------------------------------- /examples/enforce_bucket_names/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "invalid" { 2 | bucket = "example-corp-assets" 3 | } 4 | 5 | resource "aws_s3_bucket" "valid" { 6 | bucket = "example-com-assets" 7 | } 8 | 9 | variable "unknown" {} 10 | 11 | resource "aws_s3_bucket" "unknown_value" { 12 | bucket = var.unknown 13 | } 14 | 15 | resource "aws_s3_bucket" "unknown_count" { 16 | count = var.unknown 17 | 18 | bucket = "example-corp-assets" 19 | } 20 | 21 | resource "aws_s3_bucket" "unknown_for_each" { 22 | for_each = var.unknown 23 | 24 | bucket = "example-corp-assets" 25 | } 26 | -------------------------------------------------------------------------------- /examples/enforce_encrypted_devices/.tflint.d/policies/device.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | # Unexpanded resources: unknown check 6 | aws_instances_unexpanded := terraform.resources("aws_instance", {"count": "number", "for_each": "any", "dynamic": {"__labels": ["type"], "for_each": "any"}}, {"expand_mode": "none"}) 7 | 8 | aws_instance_counts contains count if { 9 | count := aws_instances_unexpanded[_].config.count 10 | } 11 | 12 | aws_instance_for_eachs contains for_each if { 13 | for_each := aws_instances_unexpanded[_].config.for_each 14 | } 15 | 16 | dynamic_ebs_block_devices contains device if { 17 | device := aws_instances_unexpanded[_].config.dynamic[_] 18 | device.labels[0] == "ebs_block_device" 19 | } 20 | 21 | # Expanded resources: encrypted flag check 22 | aws_instances := terraform.resources("aws_instance", {"ebs_block_device": {"encrypted": "bool"}}, {}) 23 | 24 | ebs_block_devices contains device if { 25 | device := aws_instances[_].config.ebs_block_device[_] 26 | } 27 | 28 | # Rules for unknown 29 | deny_unencrypted_ebs_block_device contains issue if { 30 | aws_instance_counts[i].unknown 31 | 32 | issue := tflint.issue("Dynamic value is not allowed in count", aws_instance_counts[i].range) 33 | } 34 | 35 | deny_unencrypted_ebs_block_device contains issue if { 36 | aws_instance_for_eachs[i].unknown 37 | 38 | issue := tflint.issue("Dynamic value is not allowed in for_each", aws_instance_for_eachs[i].range) 39 | } 40 | 41 | deny_unencrypted_ebs_block_device contains issue if { 42 | dynamic_ebs_block_devices[i].config.for_each.unknown 43 | 44 | issue := tflint.issue("Dynamic value is not allowed in for_each", dynamic_ebs_block_devices[i].config.for_each.range) 45 | } 46 | 47 | deny_unencrypted_ebs_block_device contains issue if { 48 | ebs_block_devices[i].config.encrypted.unknown 49 | 50 | issue := tflint.issue("Dynamic value is not allowed in encrypted", ebs_block_devices[i].config.encrypted.range) 51 | } 52 | 53 | # Rules for undefined 54 | deny_unencrypted_ebs_block_device contains issue if { 55 | ebs_block_devices[i].config == {} 56 | 57 | issue := tflint.issue("EBS block device must be encrypted", ebs_block_devices[i].decl_range) 58 | } 59 | 60 | # Rules for invaid value and null 61 | deny_unencrypted_ebs_block_device contains issue if { 62 | ebs_block_devices[i].config.encrypted.value != true 63 | 64 | issue := tflint.issue("EBS block device must be encrypted", ebs_block_devices[i].config.encrypted.range) 65 | } 66 | -------------------------------------------------------------------------------- /examples/enforce_encrypted_devices/.tflint.d/policies/device_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | failed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "invalid" { 7 | ebs_block_device { 8 | encrypted = false 9 | } 10 | }`}) 11 | 12 | test_deny_unencrypted_ebs_block_device_failed if { 13 | issues := deny_unencrypted_ebs_block_device with terraform.resources as failed_resources 14 | 15 | count(issues) == 1 16 | issue := issues[_] 17 | issue.msg == "EBS block device must be encrypted" 18 | } 19 | 20 | passed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 21 | resource "aws_instance" "valid" { 22 | ebs_block_device { 23 | encrypted = true 24 | } 25 | }`}) 26 | 27 | test_deny_unencrypted_ebs_block_device_passed if { 28 | issues := deny_unencrypted_ebs_block_device with terraform.resources as passed_resources 29 | 30 | count(issues) == 0 31 | } 32 | 33 | default_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 34 | resource "aws_instance" "default" { 35 | ebs_block_device { 36 | } 37 | }`}) 38 | 39 | test_deny_unencrypted_ebs_block_device_default if { 40 | issues := deny_unencrypted_ebs_block_device with terraform.resources as default_resources 41 | 42 | count(issues) == 1 43 | issue := issues[_] 44 | issue.msg == "EBS block device must be encrypted" 45 | } 46 | 47 | null_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 48 | resource "aws_instance" "null" { 49 | ebs_block_device { 50 | encrypted = null 51 | } 52 | }`}) 53 | 54 | test_deny_unencrypted_ebs_block_device_default if { 55 | issues := deny_unencrypted_ebs_block_device with terraform.resources as null_resources 56 | 57 | count(issues) == 1 58 | issue := issues[_] 59 | issue.msg == "EBS block device must be encrypted" 60 | } 61 | 62 | unknown_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 63 | variable "unknown" {} 64 | 65 | resource "aws_instance" "unknown" { 66 | ebs_block_device { 67 | encrypted = var.unknown 68 | } 69 | }`}) 70 | 71 | test_deny_unencrypted_ebs_block_device_unknown if { 72 | issues := deny_unencrypted_ebs_block_device with terraform.resources as unknown_resources 73 | 74 | count(issues) == 1 75 | issue := issues[_] 76 | issue.msg == "Dynamic value is not allowed in encrypted" 77 | } 78 | 79 | unknown_count_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 80 | variable "unknown" {} 81 | 82 | resource "aws_instance" "unknown_count" { 83 | count = var.unknown 84 | }`}) 85 | 86 | test_deny_unencrypted_ebs_block_device_unknown_count if { 87 | issues := deny_unencrypted_ebs_block_device with terraform.resources as unknown_count_resources 88 | 89 | count(issues) == 1 90 | issue := issues[_] 91 | issue.msg == "Dynamic value is not allowed in count" 92 | } 93 | 94 | unknown_for_each_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 95 | variable "unknown" {} 96 | 97 | resource "aws_instance" "unknown_for_each" { 98 | for_each = var.unknown 99 | }`}) 100 | 101 | test_deny_unencrypted_ebs_block_device_unknown_for_each if { 102 | issues := deny_unencrypted_ebs_block_device with terraform.resources as unknown_for_each_resources 103 | 104 | count(issues) == 1 105 | issue := issues[_] 106 | issue.msg == "Dynamic value is not allowed in for_each" 107 | } 108 | 109 | unknown_dynamic_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 110 | variable "unknown" {} 111 | 112 | resource "aws_instance" "unknown_dynamic" { 113 | dynamic "ebs_block_device" { 114 | for_each = var.unknown 115 | } 116 | }`}) 117 | 118 | test_deny_unencrypted_ebs_block_device_unknown_dynamic if { 119 | issues := deny_unencrypted_ebs_block_device with terraform.resources as unknown_dynamic_resources 120 | 121 | count(issues) == 1 122 | issue := issues[_] 123 | issue.msg == "Dynamic value is not allowed in for_each" 124 | } 125 | -------------------------------------------------------------------------------- /examples/enforce_encrypted_devices/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "opa" { 2 | enabled = true 3 | } 4 | 5 | plugin "terraform" { 6 | enabled = false 7 | } 8 | -------------------------------------------------------------------------------- /examples/enforce_encrypted_devices/README.md: -------------------------------------------------------------------------------- 1 | # Enforce encrypted devices 2 | 3 | This is an example of rules for attributes in nested blocks. 4 | 5 | ## Requirements 6 | 7 | - Disallow `encrypted = false` in EBS block devices. 8 | - Disallow device without the `encrypted` attribute because the default is `false`. 9 | - Disallow all unknown cases (unknown value, meta-arguments, dynamic blocks). 10 | - Ignore if resource not created. 11 | 12 | ## Results 13 | 14 | ```console 15 | $ tflint 16 | 8 issue(s) found: 17 | 18 | Error: EBS block device must be encrypted (opa_deny_unencrypted_ebs_block_device) 19 | 20 | on main.tf line 3: 21 | 3: encrypted = false 22 | 23 | Reference: .tflint.d/policies/device.rego:29 24 | 25 | Error: EBS block device must be encrypted (opa_deny_unencrypted_ebs_block_device) 26 | 27 | on main.tf line 14: 28 | 14: ebs_block_device { 29 | 30 | Reference: .tflint.d/policies/device.rego:29 31 | 32 | Error: EBS block device must be encrypted (opa_deny_unencrypted_ebs_block_device) 33 | 34 | on main.tf line 20: 35 | 20: encrypted = null 36 | 37 | Reference: .tflint.d/policies/device.rego:29 38 | 39 | Error: EBS block device must be encrypted (opa_deny_unencrypted_ebs_block_device) 40 | 41 | on main.tf line 29: 42 | 29: encrypted = ebs_block_device.value 43 | 44 | Reference: .tflint.d/policies/device.rego:29 45 | 46 | Error: Dynamic value is not allowed in encrypted (opa_deny_unencrypted_ebs_block_device) 47 | 48 | on main.tf line 38: 49 | 38: encrypted = var.unknown 50 | 51 | Reference: .tflint.d/policies/device.rego:29 52 | 53 | Error: Dynamic value is not allowed in count (opa_deny_unencrypted_ebs_block_device) 54 | 55 | on main.tf line 43: 56 | 43: count = var.unknown 57 | 58 | Reference: .tflint.d/policies/device.rego:29 59 | 60 | Error: Dynamic value is not allowed in for_each (opa_deny_unencrypted_ebs_block_device) 61 | 62 | on main.tf line 51: 63 | 51: for_each = var.unknown 64 | 65 | Reference: .tflint.d/policies/device.rego:29 66 | 67 | Error: Dynamic value is not allowed in for_each (opa_deny_unencrypted_ebs_block_device) 68 | 69 | on main.tf line 60: 70 | 60: for_each = var.unknown 71 | 72 | Reference: .tflint.d/policies/device.rego:29 73 | 74 | ``` 75 | -------------------------------------------------------------------------------- /examples/enforce_encrypted_devices/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "invalid" { 2 | ebs_block_device { 3 | encrypted = false 4 | } 5 | } 6 | 7 | resource "aws_instance" "valid" { 8 | ebs_block_device { 9 | encrypted = true 10 | } 11 | } 12 | 13 | resource "aws_instance" "default" { 14 | ebs_block_device { 15 | } 16 | } 17 | 18 | resource "aws_instance" "null" { 19 | ebs_block_device { 20 | encrypted = null 21 | } 22 | } 23 | 24 | resource "aws_instance" "dynamic" { 25 | dynamic "ebs_block_device" { 26 | for_each = toset([false]) 27 | 28 | content { 29 | encrypted = ebs_block_device.value 30 | } 31 | } 32 | } 33 | 34 | variable "unknown" {} 35 | 36 | resource "aws_instance" "unknown" { 37 | ebs_block_device { 38 | encrypted = var.unknown 39 | } 40 | } 41 | 42 | resource "aws_instance" "unknown_count" { 43 | count = var.unknown 44 | 45 | ebs_block_device { 46 | encrypted = false 47 | } 48 | } 49 | 50 | resource "aws_instance" "unknown_for_each" { 51 | for_each = var.unknown 52 | 53 | ebs_block_device { 54 | encrypted = false 55 | } 56 | } 57 | 58 | resource "aws_instance" "unknown_dynamic" { 59 | dynamic "ebs_block_device" { 60 | for_each = var.unknown 61 | 62 | content { 63 | encrypted = false 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /examples/enforce_modules/.tflint.d/policies/module.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_resource_declarations contains issue if { 6 | resources := terraform.resources("*", {}, {"expand_mode": "none"}) 7 | count(resources) > 0 8 | 9 | issue := tflint.issue("Declaring resources is not allowed. Use modules instead.", resources[0].decl_range) 10 | } 11 | -------------------------------------------------------------------------------- /examples/enforce_modules/.tflint.d/policies/module_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | failed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "main" { 7 | instance_type = "t2.micro" 8 | }`}) 9 | 10 | test_deny_resource_declarations_failed if { 11 | issues := deny_resource_declarations with terraform.resources as failed_resources 12 | 13 | count(issues) == 1 14 | issue := issues[_] 15 | issue.msg == "Declaring resources is not allowed. Use modules instead." 16 | } 17 | 18 | passed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 19 | module "aws_instance" { 20 | source = "../modules/aws_instance" 21 | 22 | instance_type = "t2.micro" 23 | }`}) 24 | 25 | test_deny_resource_declarations_passed if { 26 | issues := deny_resource_declarations with terraform.resources as passed_resources 27 | 28 | count(issues) == 0 29 | } 30 | -------------------------------------------------------------------------------- /examples/enforce_modules/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "opa" { 2 | enabled = true 3 | } 4 | 5 | plugin "terraform" { 6 | enabled = false 7 | } 8 | -------------------------------------------------------------------------------- /examples/enforce_modules/README.md: -------------------------------------------------------------------------------- 1 | # Enforce modules 2 | 3 | This is an example of a policy that disallows declaring resources directly and enforces you to always use modules instead. 4 | 5 | ## Requirements 6 | 7 | - Disallow all `resource` declarations. 8 | 9 | ## Results 10 | 11 | ```console 12 | $ tflint 13 | 1 issue(s) found: 14 | 15 | Error: Declaring resources is not allowed. Use modules instead. (opa_deny_resource_declarations) 16 | 17 | on main.tf line 1: 18 | 1: resource "aws_instance" "main" { 19 | 20 | Reference: .tflint.d/policies/module.rego:5 21 | 22 | ``` 23 | -------------------------------------------------------------------------------- /examples/enforce_modules/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "main" { 2 | instance_type = "t2.micro" 3 | } 4 | -------------------------------------------------------------------------------- /examples/enforce_tags/.tflint.d/policies/tags.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | aws_instances := terraform.resources("aws_instance", {"tags": "map(string)"}, {"expand_mode": "none"}) 6 | 7 | # "tags" is null 8 | is_untagged(config) if { 9 | is_null(config.tags.value) 10 | } 11 | 12 | # "tags" is defined, but "Environment" not found 13 | is_untagged(config) if { 14 | not is_null(config.tags.value) 15 | not "Environment" in object.keys(config.tags.value) 16 | } 17 | 18 | # "tags" is not defined 19 | is_untagged(config) if { 20 | not "tags" in object.keys(config) 21 | } 22 | 23 | # Rules for unknown tags 24 | deny_untagged_instance contains issue if { 25 | aws_instances[i].config.tags.unknown 26 | 27 | issue := tflint.issue("Dynamic value is not allowed in tags", aws_instances[i].config.tags.range) 28 | } 29 | 30 | # Rules for invalid tags 31 | deny_untagged_instance contains issue if { 32 | is_untagged(aws_instances[i].config) 33 | 34 | issue := tflint.issue(`instance must be tagged with the "Environment" tag`, aws_instances[i].decl_range) 35 | } 36 | -------------------------------------------------------------------------------- /examples/enforce_tags/.tflint.d/policies/tags_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | failed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "invalid" { 7 | tags = { 8 | "production" = true 9 | } 10 | }`}) 11 | 12 | test_deny_untagged_instance_failed if { 13 | issues := deny_untagged_instance with terraform.resources as failed_resources 14 | 15 | count(issues) == 1 16 | issue := issues[_] 17 | issue.msg == `instance must be tagged with the "Environment" tag` 18 | } 19 | 20 | passed_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 21 | resource "aws_instance" "valid" { 22 | tags = { 23 | "Environment" = "production" 24 | } 25 | }`}) 26 | 27 | test_deny_untagged_instance_passed if { 28 | issues := deny_untagged_instance with terraform.resources as passed_resources 29 | 30 | count(issues) == 0 31 | } 32 | 33 | undef_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 34 | resource "aws_instance" "undef" { 35 | }`}) 36 | 37 | test_deny_untagged_instance_undef if { 38 | issues := deny_untagged_instance with terraform.resources as undef_resources 39 | 40 | count(issues) == 1 41 | issue := issues[_] 42 | issue.msg == `instance must be tagged with the "Environment" tag` 43 | } 44 | 45 | null_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 46 | resource "aws_instance" "undef" { 47 | tags = null 48 | }`}) 49 | 50 | test_deny_untagged_instance_null if { 51 | issues := deny_untagged_instance with terraform.resources as null_resources 52 | 53 | count(issues) == 1 54 | issue := issues[_] 55 | issue.msg == `instance must be tagged with the "Environment" tag` 56 | } 57 | 58 | unknown_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 59 | variable "unknown" {} 60 | 61 | resource "aws_instance" "unknown" { 62 | tags = var.unknown 63 | }`}) 64 | 65 | test_deny_untagged_instance_null if { 66 | issues := deny_untagged_instance with terraform.resources as unknown_resources 67 | 68 | count(issues) == 1 69 | issue := issues[_] 70 | issue.msg == "Dynamic value is not allowed in tags" 71 | } 72 | -------------------------------------------------------------------------------- /examples/enforce_tags/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "opa" { 2 | enabled = true 3 | } 4 | 5 | plugin "terraform" { 6 | enabled = false 7 | } 8 | -------------------------------------------------------------------------------- /examples/enforce_tags/README.md: -------------------------------------------------------------------------------- 1 | # Enforce tags 2 | 3 | This is an example policy that enforces that all instances are tagged with the "Environment" tag. 4 | 5 | ## Requirements 6 | 7 | - Disallow AWS instances that are untagged with the "Environment" tag. 8 | - Disallow unknown tags. 9 | - Always warn even if the instance is not created. 10 | 11 | ## Results 12 | 13 | ```console 14 | $ tflint 15 | 4 issue(s) found: 16 | 17 | Error: instance must be tagged with the "Environment" tag (opa_deny_untagged_instance) 18 | 19 | on main.tf line 1: 20 | 1: resource "aws_instance" "invalid" { 21 | 22 | Reference: .tflint.d/policies/tags.rego:24 23 | 24 | Error: instance must be tagged with the "Environment" tag (opa_deny_untagged_instance) 25 | 26 | on main.tf line 13: 27 | 13: resource "aws_instance" "undefined" { 28 | 29 | Reference: .tflint.d/policies/tags.rego:24 30 | 31 | Error: instance must be tagged with the "Environment" tag (opa_deny_untagged_instance) 32 | 33 | on main.tf line 16: 34 | 16: resource "aws_instance" "null" { 35 | 36 | Reference: .tflint.d/policies/tags.rego:24 37 | 38 | Error: Dynamic value is not allowed in tags (opa_deny_untagged_instance) 39 | 40 | on main.tf line 23: 41 | 23: tags = var.unknown 42 | 43 | Reference: .tflint.d/policies/tags.rego:24 44 | 45 | ``` 46 | -------------------------------------------------------------------------------- /examples/enforce_tags/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "invalid" { 2 | tags = { 3 | "production" = true 4 | } 5 | } 6 | 7 | resource "aws_instance" "valid" { 8 | tags = { 9 | "Environment" = "production" 10 | } 11 | } 12 | 13 | resource "aws_instance" "undefined" { 14 | } 15 | 16 | resource "aws_instance" "null" { 17 | tags = null 18 | } 19 | 20 | variable "unknown" {} 21 | 22 | resource "aws_instance" "unknown" { 23 | tags = var.unknown 24 | } 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/terraform-linters/tflint-ruleset-opa 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/google/go-cmp v0.7.0 7 | github.com/hashicorp/go-hclog v1.6.3 8 | github.com/hashicorp/hcl/v2 v2.23.0 9 | github.com/liamg/memoryfs v1.6.0 10 | github.com/mitchellh/go-homedir v1.1.0 11 | github.com/open-policy-agent/opa v1.4.2 12 | github.com/terraform-linters/tflint-plugin-sdk v0.22.0 13 | github.com/zclconf/go-cty v1.16.3 14 | ) 15 | 16 | require ( 17 | github.com/agext/levenshtein v1.2.1 // indirect 18 | github.com/agnivade/levenshtein v1.2.1 // indirect 19 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 20 | github.com/beorn7/perks v1.0.1 // indirect 21 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 22 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 23 | github.com/fatih/color v1.14.1 // indirect 24 | github.com/go-ini/ini v1.67.0 // indirect 25 | github.com/go-logr/logr v1.4.2 // indirect 26 | github.com/go-logr/stdr v1.2.2 // indirect 27 | github.com/gobwas/glob v0.2.3 // indirect 28 | github.com/golang/protobuf v1.5.4 // indirect 29 | github.com/google/uuid v1.6.0 // indirect 30 | github.com/gorilla/mux v1.8.1 // indirect 31 | github.com/hashicorp/go-plugin v1.6.2 // indirect 32 | github.com/hashicorp/go-version v1.7.0 // indirect 33 | github.com/hashicorp/yamux v0.1.1 // indirect 34 | github.com/mattn/go-colorable v0.1.13 // indirect 35 | github.com/mattn/go-isatty v0.0.17 // indirect 36 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect 37 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 38 | github.com/oklog/run v1.0.0 // indirect 39 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 40 | github.com/prometheus/client_golang v1.21.1 // indirect 41 | github.com/prometheus/client_model v0.6.1 // indirect 42 | github.com/prometheus/common v0.62.0 // indirect 43 | github.com/prometheus/procfs v0.15.1 // indirect 44 | github.com/rcrowley/go-metrics v0.0.0-20200313005456-10cdbea86bc0 // indirect 45 | github.com/sirupsen/logrus v1.9.3 // indirect 46 | github.com/tchap/go-patricia/v2 v2.3.2 // indirect 47 | github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect 48 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 49 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect 50 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 51 | github.com/yashtewari/glob-intersection v0.2.0 // indirect 52 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 53 | go.opentelemetry.io/otel v1.35.0 // indirect 54 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 55 | go.opentelemetry.io/otel/sdk v1.35.0 // indirect 56 | go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect 57 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 58 | golang.org/x/mod v0.22.0 // indirect 59 | golang.org/x/net v0.38.0 // indirect 60 | golang.org/x/sync v0.12.0 // indirect 61 | golang.org/x/sys v0.31.0 // indirect 62 | golang.org/x/text v0.23.0 // indirect 63 | golang.org/x/tools v0.29.0 // indirect 64 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 65 | google.golang.org/grpc v1.71.1 // indirect 66 | google.golang.org/protobuf v1.36.6 // indirect 67 | gopkg.in/yaml.v3 v3.0.1 // indirect 68 | sigs.k8s.io/yaml v1.4.0 // indirect 69 | ) 70 | -------------------------------------------------------------------------------- /integration/checks/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/checks/main.tf: -------------------------------------------------------------------------------- 1 | check "health_check" { 2 | data "http" "terraform_io" { 3 | url = "https://www.terraform.io" 4 | } 5 | 6 | assert { 7 | condition = data.http.terraform_io.status_code == 200 8 | error_message = "${data.http.terraform_io.url} returned an unhealthy status code" 9 | } 10 | } 11 | 12 | check "deterministic" { 13 | assert { 14 | condition = 200 == 200 15 | error_message = "condition should be true" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /integration/checks/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_deterministic_check_condition contains issue if { 6 | checks := terraform.checks({"assert": {"condition": "bool"}}, {}) 7 | condition = checks[_].config.assert[_].config.condition 8 | condition.unknown == false 9 | 10 | issue := tflint.issue("deterministic check condtion is not allowed", condition.range) 11 | } 12 | -------------------------------------------------------------------------------- /integration/checks/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_checks(schema, options) := terraform.mock_checks(schema, options, {"main.tf": ` 6 | check "deterministic" { 7 | assert { 8 | condition = 200 == 200 9 | error_message = "condition should be true" 10 | } 11 | }`}) 12 | 13 | test_deny_deterministic_check_condition_passed if { 14 | issues := deny_deterministic_check_condition with terraform.checks as mock_checks 15 | 16 | count(issues) == 1 17 | issue := issues[_] 18 | issue.msg == "deterministic check condtion is not allowed" 19 | } 20 | 21 | test_deny_deterministic_check_condition_failed if { 22 | issues := deny_deterministic_check_condition with terraform.checks as mock_checks 23 | 24 | count(issues) == 0 25 | } 26 | -------------------------------------------------------------------------------- /integration/checks/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_deterministic_check_condition", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "deterministic check condtion is not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 14, 14 | "column": 17 15 | }, 16 | "end": { 17 | "line": 14, 18 | "column": 27 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/checks/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_deterministic_check_condition_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:21" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/data_sources/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/data_sources/main.tf: -------------------------------------------------------------------------------- 1 | data "aws_ami" "valid" { 2 | owners = ["self"] 3 | } 4 | 5 | data "aws_ami" "invalid" { 6 | owners = ["amazon"] 7 | } 8 | 9 | check "scoped" { 10 | data "aws_ami" "scoped_valid" { 11 | owners = ["self"] 12 | } 13 | 14 | data "aws_ami" "scoped_invalid" { 15 | owners = ["amazon"] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /integration/data_sources/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_other_ami_owners contains issue if { 6 | sources := terraform.data_sources("aws_ami", {"owners": "list(string)"}, {}) 7 | owners := sources[_].config.owners 8 | 9 | owners.value[_] != "self" 10 | 11 | issue := tflint.issue("third-party AMI is not allowed", owners.range) 12 | } 13 | -------------------------------------------------------------------------------- /integration/data_sources/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_data_sources(type, schema, options) := terraform.mock_data_sources(type, schema, options, {"main.tf": ` 6 | data "aws_ami" "main" { 7 | owners = ["amazon"] 8 | } 9 | 10 | check "scope" { 11 | data "aws_ami" "scoped" { 12 | owners = ["amazon"] 13 | } 14 | }`}) 15 | 16 | test_deny_other_ami_owners_passed if { 17 | issues := deny_other_ami_owners with terraform.data_sources as mock_data_sources 18 | 19 | count(issues) == 2 20 | issue := issues[_] 21 | issue.msg == "third-party AMI is not allowed" 22 | } 23 | 24 | test_deny_other_ami_owners_failed if { 25 | issues := deny_other_ami_owners with terraform.data_sources as mock_data_sources 26 | 27 | count(issues) == 0 28 | } 29 | -------------------------------------------------------------------------------- /integration/data_sources/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_other_ami_owners", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "third-party AMI is not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 6, 14 | "column": 12 15 | }, 16 | "end": { 17 | "line": 6, 18 | "column": 22 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_deny_other_ami_owners", 26 | "severity": "error", 27 | "link": "policies/main.rego:5" 28 | }, 29 | "message": "third-party AMI is not allowed", 30 | "range": { 31 | "filename": "main.tf", 32 | "start": { 33 | "line": 15, 34 | "column": 14 35 | }, 36 | "end": { 37 | "line": 15, 38 | "column": 24 39 | } 40 | }, 41 | "callers": [] 42 | } 43 | ], 44 | "errors": [] 45 | } 46 | -------------------------------------------------------------------------------- /integration/data_sources/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_other_ami_owners_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:24" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/ephemerals/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/ephemerals/main.tf: -------------------------------------------------------------------------------- 1 | ephemeral "random_password" "db_password" { 2 | length = 16 3 | override_special = "!#$%&*()-_=+[]{}<>:?" 4 | } 5 | 6 | resource "aws_secretsmanager_secret" "db_password" { 7 | name = "db_password" 8 | } 9 | 10 | resource "aws_secretsmanager_secret_version" "db_password" { 11 | secret_id = aws_secretsmanager_secret.db_password.id 12 | secret_string_wo = ephemeral.random_password.db_password.result 13 | secret_string_wo_version = 1 14 | } 15 | 16 | ephemeral "aws_secretsmanager_secret_version" "db_password" { 17 | secret_id = aws_secretsmanager_secret_version.db_password.secret_id 18 | } 19 | -------------------------------------------------------------------------------- /integration/ephemerals/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_weak_password contains issue if { 6 | passwords := terraform.ephemeral_resources("random_password", {"length": "number"}, {}) 7 | length := passwords[_].config.length 8 | length.value < 32 9 | 10 | issue := tflint.issue("Password must be at least 32 characters long", length.range) 11 | } 12 | -------------------------------------------------------------------------------- /integration/ephemerals/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | import future.keywords 3 | 4 | mock_ephemeral_resources(type, schema, options) := terraform.mock_ephemeral_resources(type, schema, options, {"main.tf": ` 5 | ephemeral "random_password" "db_password" { 6 | length = 16 7 | override_special = "!#$%&*()-_=+[]{}<>:?" 8 | }`}) 9 | 10 | test_deny_weak_password_passed if { 11 | issues := deny_weak_password with terraform.ephemeral_resources as mock_ephemeral_resources 12 | 13 | count(issues) == 1 14 | issue := issues[_] 15 | issue.msg == "Password must be at least 32 characters long" 16 | } 17 | 18 | test_deny_weak_password_failed if { 19 | issues := deny_weak_password with terraform.ephemeral_resources as mock_ephemeral_resources 20 | 21 | count(issues) == 0 22 | } 23 | -------------------------------------------------------------------------------- /integration/ephemerals/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_weak_password", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "Password must be at least 32 characters long", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 2, 14 | "column": 22 15 | }, 16 | "end": { 17 | "line": 2, 18 | "column": 24 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/ephemerals/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_weak_password_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:18" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/imports/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/imports/main.tf: -------------------------------------------------------------------------------- 1 | import { 2 | to = aws_instance.example 3 | id = "i-abcd1234" 4 | } 5 | -------------------------------------------------------------------------------- /integration/imports/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_import_blocks contains issue if { 6 | imports := terraform.imports({}, {}) 7 | count(imports) > 0 8 | 9 | issue := tflint.issue("import blocks are not allowed", imports[0].decl_range) 10 | } 11 | -------------------------------------------------------------------------------- /integration/imports/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_imports(schema, options) := terraform.mock_imports(schema, options, {"main.tf": ` 6 | import { 7 | to = aws_instance.example 8 | id = "i-abcd1234" 9 | }`}) 10 | 11 | test_deny_import_blocks_passed if { 12 | issues := deny_import_blocks with terraform.imports as mock_imports 13 | 14 | count(issues) == 1 15 | issue := issues[_] 16 | issue.msg == "import blocks are not allowed" 17 | } 18 | 19 | test_deny_import_blocks_failed if { 20 | issues := deny_import_blocks with terraform.imports as mock_imports 21 | 22 | count(issues) == 0 23 | } 24 | -------------------------------------------------------------------------------- /integration/imports/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_import_blocks", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "import blocks are not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 1, 14 | "column": 1 15 | }, 16 | "end": { 17 | "line": 1, 18 | "column": 7 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/imports/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_import_blocks_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:19" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/instance_type/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/instance_type/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "invalid" { 2 | instance_type = "t1.micro" 3 | } 4 | 5 | resource "aws_instance" "valid" { 6 | instance_type = "t2.micro" 7 | } 8 | 9 | variable "variable" { 10 | default = "m5.large" 11 | } 12 | resource "aws_instance" "variable" { 13 | instance_type = var.variable 14 | } 15 | 16 | variable "unknown" {} 17 | resource "aws_instance" "variable" { 18 | instance_type = var.unknown 19 | } 20 | 21 | variable "sensitive" { 22 | default = "m5.large" 23 | sensitive = true 24 | } 25 | resource "aws_instance" "sensitive" { 26 | instance_type = var.sensitive 27 | } 28 | 29 | variable "ephemeral" { 30 | default = "m5.large" 31 | ephemeral = true 32 | } 33 | resource "aws_instance" "ephemeral" { 34 | instance_type = var.ephemeral 35 | } 36 | -------------------------------------------------------------------------------- /integration/instance_type/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_not_t2_micro contains issue if { 6 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 7 | instance_type := resources[_].config.instance_type 8 | 9 | instance_type.unknown == true 10 | 11 | issue := tflint.issue("instance type is unknown", instance_type.range) 12 | } 13 | 14 | deny_not_t2_micro contains issue if { 15 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 16 | instance_type := resources[_].config.instance_type 17 | 18 | instance_type.sensitive == true 19 | 20 | issue := tflint.issue("instance type is sensitive", instance_type.range) 21 | } 22 | 23 | deny_not_t2_micro contains issue if { 24 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 25 | instance_type := resources[_].config.instance_type 26 | 27 | instance_type.value != "t2.micro" 28 | 29 | issue := tflint.issue("t2.micro is only allowed", instance_type.range) 30 | } 31 | 32 | deny_not_t2_micro contains issue if { 33 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 34 | instance_type := resources[_].config.instance_type 35 | 36 | instance_type.ephemeral == true 37 | 38 | issue := tflint.issue("instance type is ephemeral", instance_type.range) 39 | } 40 | -------------------------------------------------------------------------------- /integration/instance_type/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_resources_t1_micro(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "main" { 7 | instance_type = "t1.micro" 8 | }`}) 9 | 10 | test_not_deny_t2_micro_passed if { 11 | issues := deny_not_t2_micro with terraform.resources as mock_resources_t1_micro 12 | 13 | count(issues) == 1 14 | issue := issues[_] 15 | issue.msg == "t2.micro is only allowed" 16 | } 17 | 18 | test_not_deny_t2_micro_failed if { 19 | issues := deny_not_t2_micro with terraform.resources as mock_resources_t1_micro 20 | 21 | count(issues) == 0 22 | } 23 | 24 | mock_resources_unknown_instance_type(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 25 | variable "unknown" {} 26 | resource "aws_instance" "main" { 27 | instance_type = var.unknown 28 | }`}) 29 | 30 | test_not_deny_t2_micro_unknown_passed if { 31 | issues := deny_not_t2_micro with terraform.resources as mock_resources_unknown_instance_type 32 | 33 | count(issues) == 1 34 | issue := issues[_] 35 | issue.msg == "instance type is unknown" 36 | } 37 | 38 | test_not_deny_t2_micro_unknown_failed if { 39 | issues := deny_not_t2_micro with terraform.resources as mock_resources_unknown_instance_type 40 | 41 | count(issues) == 0 42 | } 43 | 44 | mock_resources_sensitive_instance_type(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 45 | variable "sensitive" { 46 | default = "t2.micro" 47 | sensitive = true 48 | } 49 | resource "aws_instance" "main" { 50 | instance_type = var.sensitive 51 | }`}) 52 | 53 | test_not_deny_t2_micro_sensitive_passed if { 54 | issues := deny_not_t2_micro with terraform.resources as mock_resources_sensitive_instance_type 55 | 56 | count(issues) == 2 57 | } 58 | 59 | test_not_deny_t2_micro_sensitive_failed if { 60 | issues := deny_not_t2_micro with terraform.resources as mock_resources_sensitive_instance_type 61 | 62 | count(issues) == 0 63 | } 64 | 65 | mock_resources_ephemeral_instance_type(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 66 | variable "ephemeral" { 67 | default = "t2.micro" 68 | ephemeral = true 69 | } 70 | resource "aws_instance" "main" { 71 | instance_type = var.ephemeral 72 | }`}) 73 | 74 | test_not_deny_t2_micro_ephemeral_passed if { 75 | issues := deny_not_t2_micro with terraform.resources as mock_resources_ephemeral_instance_type 76 | 77 | count(issues) == 2 78 | } 79 | 80 | test_not_deny_t2_micro_ephemeral_failed if { 81 | issues := deny_not_t2_micro with terraform.resources as mock_resources_ephemeral_instance_type 82 | 83 | count(issues) == 0 84 | } 85 | -------------------------------------------------------------------------------- /integration/instance_type/result-v0.43.0.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_not_t2_micro", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "t2.micro is only allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 2, 14 | "column": 19 15 | }, 16 | "end": { 17 | "line": 2, 18 | "column": 29 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_deny_not_t2_micro", 26 | "severity": "error", 27 | "link": "policies/main.rego:5" 28 | }, 29 | "message": "t2.micro is only allowed", 30 | "range": { 31 | "filename": "main.tf", 32 | "start": { 33 | "line": 13, 34 | "column": 19 35 | }, 36 | "end": { 37 | "line": 13, 38 | "column": 31 39 | } 40 | }, 41 | "callers": [] 42 | }, 43 | { 44 | "rule": { 45 | "name": "opa_deny_not_t2_micro", 46 | "severity": "error", 47 | "link": "policies/main.rego:5" 48 | }, 49 | "message": "instance type is unknown", 50 | "range": { 51 | "filename": "main.tf", 52 | "start": { 53 | "line": 18, 54 | "column": 19 55 | }, 56 | "end": { 57 | "line": 18, 58 | "column": 30 59 | } 60 | }, 61 | "callers": [] 62 | }, 63 | { 64 | "rule": { 65 | "name": "opa_deny_not_t2_micro", 66 | "severity": "error", 67 | "link": "policies/main.rego:5" 68 | }, 69 | "message": "instance type is sensitive", 70 | "range": { 71 | "filename": "main.tf", 72 | "start": { 73 | "line": 26, 74 | "column": 19 75 | }, 76 | "end": { 77 | "line": 26, 78 | "column": 32 79 | } 80 | }, 81 | "callers": [] 82 | }, 83 | { 84 | "rule": { 85 | "name": "opa_deny_not_t2_micro", 86 | "severity": "error", 87 | "link": "policies/main.rego:5" 88 | }, 89 | "message": "instance type is unknown", 90 | "range": { 91 | "filename": "main.tf", 92 | "start": { 93 | "line": 26, 94 | "column": 19 95 | }, 96 | "end": { 97 | "line": 26, 98 | "column": 32 99 | } 100 | }, 101 | "callers": [] 102 | }, 103 | { 104 | "rule": { 105 | "name": "opa_deny_not_t2_micro", 106 | "severity": "error", 107 | "link": "policies/main.rego:5" 108 | }, 109 | "message": "t2.micro is only allowed", 110 | "range": { 111 | "filename": "main.tf", 112 | "start": { 113 | "line": 34, 114 | "column": 19 115 | }, 116 | "end": { 117 | "line": 34, 118 | "column": 32 119 | } 120 | }, 121 | "callers": [] 122 | } 123 | ], 124 | "errors": [] 125 | } 126 | -------------------------------------------------------------------------------- /integration/instance_type/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_not_t2_micro", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "t2.micro is only allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 2, 14 | "column": 19 15 | }, 16 | "end": { 17 | "line": 2, 18 | "column": 29 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_deny_not_t2_micro", 26 | "severity": "error", 27 | "link": "policies/main.rego:5" 28 | }, 29 | "message": "t2.micro is only allowed", 30 | "range": { 31 | "filename": "main.tf", 32 | "start": { 33 | "line": 13, 34 | "column": 19 35 | }, 36 | "end": { 37 | "line": 13, 38 | "column": 31 39 | } 40 | }, 41 | "callers": [] 42 | }, 43 | { 44 | "rule": { 45 | "name": "opa_deny_not_t2_micro", 46 | "severity": "error", 47 | "link": "policies/main.rego:5" 48 | }, 49 | "message": "instance type is unknown", 50 | "range": { 51 | "filename": "main.tf", 52 | "start": { 53 | "line": 18, 54 | "column": 19 55 | }, 56 | "end": { 57 | "line": 18, 58 | "column": 30 59 | } 60 | }, 61 | "callers": [] 62 | }, 63 | { 64 | "rule": { 65 | "name": "opa_deny_not_t2_micro", 66 | "severity": "error", 67 | "link": "policies/main.rego:5" 68 | }, 69 | "message": "instance type is sensitive", 70 | "range": { 71 | "filename": "main.tf", 72 | "start": { 73 | "line": 26, 74 | "column": 19 75 | }, 76 | "end": { 77 | "line": 26, 78 | "column": 32 79 | } 80 | }, 81 | "callers": [] 82 | }, 83 | { 84 | "rule": { 85 | "name": "opa_deny_not_t2_micro", 86 | "severity": "error", 87 | "link": "policies/main.rego:5" 88 | }, 89 | "message": "instance type is unknown", 90 | "range": { 91 | "filename": "main.tf", 92 | "start": { 93 | "line": 26, 94 | "column": 19 95 | }, 96 | "end": { 97 | "line": 26, 98 | "column": 32 99 | } 100 | }, 101 | "callers": [] 102 | }, 103 | { 104 | "rule": { 105 | "name": "opa_deny_not_t2_micro", 106 | "severity": "error", 107 | "link": "policies/main.rego:5" 108 | }, 109 | "message": "instance type is ephemeral", 110 | "range": { 111 | "filename": "main.tf", 112 | "start": { 113 | "line": 34, 114 | "column": 19 115 | }, 116 | "end": { 117 | "line": 34, 118 | "column": 32 119 | } 120 | }, 121 | "callers": [] 122 | }, 123 | { 124 | "rule": { 125 | "name": "opa_deny_not_t2_micro", 126 | "severity": "error", 127 | "link": "policies/main.rego:5" 128 | }, 129 | "message": "instance type is unknown", 130 | "range": { 131 | "filename": "main.tf", 132 | "start": { 133 | "line": 34, 134 | "column": 19 135 | }, 136 | "end": { 137 | "line": 34, 138 | "column": 32 139 | } 140 | }, 141 | "callers": [] 142 | } 143 | ], 144 | "errors": [] 145 | } 146 | -------------------------------------------------------------------------------- /integration/instance_type/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_not_deny_t2_micro_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:18" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_test_not_deny_t2_micro_unknown_failed", 26 | "severity": "error", 27 | "link": "policies/main_test.rego:38" 28 | }, 29 | "message": "test failed", 30 | "range": { 31 | "filename": "", 32 | "start": { 33 | "line": 0, 34 | "column": 0 35 | }, 36 | "end": { 37 | "line": 0, 38 | "column": 0 39 | } 40 | }, 41 | "callers": [] 42 | }, 43 | { 44 | "rule": { 45 | "name": "opa_test_not_deny_t2_micro_sensitive_failed", 46 | "severity": "error", 47 | "link": "policies/main_test.rego:59" 48 | }, 49 | "message": "test failed", 50 | "range": { 51 | "filename": "", 52 | "start": { 53 | "line": 0, 54 | "column": 0 55 | }, 56 | "end": { 57 | "line": 0, 58 | "column": 0 59 | } 60 | }, 61 | "callers": [] 62 | }, 63 | { 64 | "rule": { 65 | "name": "opa_test_not_deny_t2_micro_ephemeral_failed", 66 | "severity": "error", 67 | "link": "policies/main_test.rego:80" 68 | }, 69 | "message": "test failed", 70 | "range": { 71 | "filename": "", 72 | "start": { 73 | "line": 0, 74 | "column": 0 75 | }, 76 | "end": { 77 | "line": 0, 78 | "column": 0 79 | } 80 | }, 81 | "callers": [] 82 | } 83 | ], 84 | "errors": [] 85 | } 86 | -------------------------------------------------------------------------------- /integration/locals/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/locals/main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | foo = "foo" 3 | bar = "bar" 4 | baz = "baz" 5 | 6 | a = 1 7 | b = 2 8 | c = 3 9 | } 10 | -------------------------------------------------------------------------------- /integration/locals/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_too_many_locals contains issue if { 6 | locals := terraform.locals({}) 7 | count(locals) > 5 8 | 9 | issue := tflint.issue("too many local values", terraform.module_range()) 10 | } 11 | -------------------------------------------------------------------------------- /integration/locals/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_locals(options) := terraform.mock_locals(options, {}) 6 | 7 | test_deny_too_many_locals_passed if { 8 | issues := deny_too_many_locals with terraform.locals as mock_locals 9 | 10 | count(issues) == 1 11 | issue := issues[_] 12 | issue.msg == "module must expose outputs" 13 | } 14 | 15 | test_deny_too_many_locals_failed if { 16 | issues := deny_too_many_locals with terraform.locals as mock_locals 17 | 18 | count(issues) == 0 19 | } 20 | -------------------------------------------------------------------------------- /integration/locals/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_too_many_locals", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "too many local values", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 1, 14 | "column": 1 15 | }, 16 | "end": { 17 | "line": 1, 18 | "column": 1 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/locals/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_too_many_locals_passed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:7" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/module_calls/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/module_calls/main.tf: -------------------------------------------------------------------------------- 1 | module "local" { 2 | source = "./module" 3 | } 4 | 5 | module "remote" { 6 | source = "github.com/hashicorp/example" 7 | } 8 | -------------------------------------------------------------------------------- /integration/module_calls/module/main.tf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terraform-linters/tflint-ruleset-opa/9d25b675c5b83a52824cf15c5fa3afd50f108bb7/integration/module_calls/module/main.tf -------------------------------------------------------------------------------- /integration/module_calls/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_remote_source contains issue if { 6 | modules := terraform.module_calls({"source": "string"}, {}) 7 | source := modules[_].config.source 8 | 9 | not startswith(source.value, "./") 10 | 11 | issue := tflint.issue("remote module is not allowed", source.range) 12 | } 13 | -------------------------------------------------------------------------------- /integration/module_calls/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_module_calls(schema, options) := terraform.mock_module_calls(schema, options, {"main.tf": ` 6 | module "remote" { 7 | source = "github.com/hashicorp/example" 8 | }`}) 9 | 10 | test_deny_remote_source_passed if { 11 | issues := deny_remote_source with terraform.module_calls as mock_module_calls 12 | 13 | count(issues) == 1 14 | issue := issues[_] 15 | issue.msg == "remote module is not allowed" 16 | } 17 | 18 | test_deny_remote_source_failed if { 19 | issues := deny_remote_source with terraform.module_calls as mock_module_calls 20 | 21 | count(issues) == 0 22 | } 23 | -------------------------------------------------------------------------------- /integration/module_calls/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_remote_source", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "remote module is not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 6, 14 | "column": 12 15 | }, 16 | "end": { 17 | "line": 6, 18 | "column": 42 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/module_calls/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_remote_source_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:18" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/moved/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/moved/main.tf: -------------------------------------------------------------------------------- 1 | moved { 2 | from = aws_instance.foo 3 | to = aws_instance.bar 4 | } 5 | -------------------------------------------------------------------------------- /integration/moved/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_moved_blocks contains issue if { 6 | moved := terraform.moved_blocks({}, {}) 7 | count(moved) > 0 8 | 9 | issue := tflint.issue("moved blocks are not allowed", moved[0].decl_range) 10 | } 11 | -------------------------------------------------------------------------------- /integration/moved/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_moved_blocks(schema, options) := terraform.mock_moved_blocks(schema, options, {"main.tf": ` 6 | moved { 7 | from = aws_instance.foo 8 | to = aws_instance.bar 9 | }`}) 10 | 11 | test_deny_moved_blocks_passed if { 12 | issues := deny_moved_blocks with terraform.moved_blocks as mock_moved_blocks 13 | 14 | count(issues) == 1 15 | issue := issues[_] 16 | issue.msg == "moved blocks are not allowed" 17 | } 18 | 19 | test_deny_moved_blocks_failed if { 20 | issues := deny_moved_blocks with terraform.moved_blocks as mock_moved_blocks 21 | 22 | count(issues) == 0 23 | } 24 | -------------------------------------------------------------------------------- /integration/moved/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_moved_blocks", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "moved blocks are not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 1, 14 | "column": 1 15 | }, 16 | "end": { 17 | "line": 1, 18 | "column": 6 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/moved/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_moved_blocks_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:19" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/naming_convention/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/naming_convention/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "valid_name" { 2 | instance_type = "t2.micro" 3 | } 4 | 5 | resource "aws_s3_bucket" "valid_name" { 6 | bucket = "foo" 7 | } 8 | 9 | resource "aws_instance" "invalid-name" { 10 | instance_type = "t2.micro" 11 | } 12 | 13 | resource "aws_s3_bucket" "invalid-name" { 14 | bucket = "foo" 15 | } 16 | -------------------------------------------------------------------------------- /integration/naming_convention/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_not_snake_case contains issue if { 6 | resources := terraform.resources("*", {}, {}) 7 | not regex.match("^[a-z][a-z0-9]*(_[a-z0-9]+)*$", resources[i].name) 8 | 9 | issue := tflint.issue(sprintf("%s is not snake case", [resources[i].name]), resources[i].decl_range) 10 | } 11 | -------------------------------------------------------------------------------- /integration/naming_convention/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "main-v2" {} 7 | `}) 8 | 9 | test_deny_not_snake_case_passed if { 10 | issues := deny_not_snake_case with terraform.resources as mock_resources 11 | 12 | count(issues) == 1 13 | issue := issues[_] 14 | issue.msg == "main-v2 is not snake case" 15 | issue.range.start.line == 2 16 | } 17 | 18 | test_deny_not_snake_case_failed if { 19 | issues := deny_not_snake_case with terraform.resources as mock_resources 20 | 21 | count(issues) == 0 22 | } 23 | -------------------------------------------------------------------------------- /integration/naming_convention/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_not_snake_case", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "invalid-name is not snake case", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 9, 14 | "column": 1 15 | }, 16 | "end": { 17 | "line": 9, 18 | "column": 39 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_deny_not_snake_case", 26 | "severity": "error", 27 | "link": "policies/main.rego:5" 28 | }, 29 | "message": "invalid-name is not snake case", 30 | "range": { 31 | "filename": "main.tf", 32 | "start": { 33 | "line": 13, 34 | "column": 1 35 | }, 36 | "end": { 37 | "line": 13, 38 | "column": 40 39 | } 40 | }, 41 | "callers": [] 42 | } 43 | ], 44 | "errors": [] 45 | } 46 | -------------------------------------------------------------------------------- /integration/naming_convention/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_not_snake_case_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:18" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/outputs/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/outputs/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_no_outputs contains issue if { 6 | outputs := terraform.outputs({}, {}) 7 | count(outputs) == 0 8 | 9 | issue := tflint.issue("module must expose outputs", terraform.module_range()) 10 | } 11 | -------------------------------------------------------------------------------- /integration/outputs/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_outputs(schema, options) := terraform.mock_outputs(schema, options, {}) 6 | 7 | test_deny_no_outputs_passed if { 8 | issues := deny_no_outputs with terraform.outputs as mock_outputs 9 | 10 | count(issues) == 1 11 | issue := issues[_] 12 | issue.msg == "module must expose outputs" 13 | issue.range.start.line == 1 14 | } 15 | 16 | test_deny_no_outputs_failed if { 17 | issues := deny_no_outputs with terraform.outputs as mock_outputs 18 | 19 | count(issues) == 0 20 | } 21 | -------------------------------------------------------------------------------- /integration/outputs/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_no_outputs", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "module must expose outputs", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 1, 14 | "column": 1 15 | }, 16 | "end": { 17 | "line": 1, 18 | "column": 1 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/outputs/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_no_outputs_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:16" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/providers/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/providers/main.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | alias = "east" 3 | region = "us-east-1" 4 | } 5 | 6 | resource "aws_instance" "main" { 7 | provider = aws.east 8 | } 9 | -------------------------------------------------------------------------------- /integration/providers/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_us_east_1 contains issue if { 6 | providers := terraform.providers({"region": "string"}, {}) 7 | region := providers[_].config.region 8 | 9 | region.value == "us-east-1" 10 | 11 | issue := tflint.issue("us-east-1 is not allowed", region.range) 12 | } 13 | 14 | deny_provider_ref contains issue if { 15 | resources := terraform.resources("*", {"provider": "any"}, {}) 16 | provider := resources[_].config.provider 17 | 18 | issue := tflint.issue("provider reference is not allowed", provider.range) 19 | } 20 | -------------------------------------------------------------------------------- /integration/providers/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_providers(schema, options) := terraform.mock_providers(schema, options, {"main.tf": ` 6 | provider "aws" { 7 | alias = "east" 8 | region = "us-east-1" 9 | }`}) 10 | 11 | test_deny_us_east_1_passed if { 12 | issues := deny_us_east_1 with terraform.providers as mock_providers 13 | 14 | count(issues) == 1 15 | issue := issues[_] 16 | issue.msg == "us-east-1 is not allowed" 17 | } 18 | 19 | test_deny_us_east_1_failed if { 20 | issues := deny_us_east_1 with terraform.providers as mock_providers 21 | 22 | count(issues) == 0 23 | } 24 | -------------------------------------------------------------------------------- /integration/providers/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_us_east_1", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "us-east-1 is not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 3, 14 | "column": 12 15 | }, 16 | "end": { 17 | "line": 3, 18 | "column": 23 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_deny_provider_ref", 26 | "severity": "error", 27 | "link": "policies/main.rego:14" 28 | }, 29 | "message": "provider reference is not allowed", 30 | "range": { 31 | "filename": "main.tf", 32 | "start": { 33 | "line": 7, 34 | "column": 14 35 | }, 36 | "end": { 37 | "line": 7, 38 | "column": 22 39 | } 40 | }, 41 | "callers": [] 42 | } 43 | ], 44 | "errors": [] 45 | } 46 | -------------------------------------------------------------------------------- /integration/providers/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_us_east_1_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:19" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/removed/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/removed/main.tf: -------------------------------------------------------------------------------- 1 | removed { 2 | from = aws_instance.example 3 | 4 | lifecycle { 5 | destroy = false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /integration/removed/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_removed_blocks contains issue if { 6 | moved := terraform.removed_blocks({}, {}) 7 | count(moved) > 0 8 | 9 | issue := tflint.issue("removed blocks are not allowed", moved[0].decl_range) 10 | } 11 | -------------------------------------------------------------------------------- /integration/removed/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_removed_blocks(schema, options) := terraform.mock_removed_blocks(schema, options, {"main.tf": ` 6 | removed { 7 | from = aws_instance.example 8 | 9 | lifecycle { 10 | destroy = false 11 | } 12 | }`}) 13 | 14 | test_deny_removed_blocks_passed if { 15 | issues := deny_removed_blocks with terraform.removed_blocks as mock_removed_blocks 16 | 17 | count(issues) == 1 18 | issue := issues[_] 19 | issue.msg == "removed blocks are not allowed" 20 | } 21 | 22 | test_deny_removed_blocks_failed if { 23 | issues := deny_removed_blocks with terraform.removed_blocks as mock_removed_blocks 24 | 25 | count(issues) == 0 26 | } 27 | -------------------------------------------------------------------------------- /integration/removed/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_removed_blocks", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "removed blocks are not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 1, 14 | "column": 1 15 | }, 16 | "end": { 17 | "line": 1, 18 | "column": 8 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/removed/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_removed_blocks_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:22" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/resources/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/resources/main.tf: -------------------------------------------------------------------------------- 1 | variable "instance_type" {} 2 | -------------------------------------------------------------------------------- /integration/resources/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_all_resources contains issue if { 6 | resources := terraform.resources("*", {}, {}) 7 | count(resources) > 0 8 | 9 | issue := tflint.issue("resource is not allowed", resources[0].decl_range) 10 | } 11 | 12 | deny_no_resources contains issue if { 13 | resources := terraform.resources("*", {}, {}) 14 | count(resources) == 0 15 | 16 | issue := tflint.issue("resources should be declared", terraform.module_range()) 17 | } 18 | -------------------------------------------------------------------------------- /integration/resources/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "main" {} 7 | `}) 8 | 9 | test_deny_all_resources_passed if { 10 | issues := deny_all_resources with terraform.resources as mock_resources 11 | 12 | count(issues) == 1 13 | issue := issues[_] 14 | issue.msg == "resource is not allowed" 15 | } 16 | 17 | test_deny_all_resources_failed if { 18 | issues := deny_all_resources with terraform.resources as mock_resources 19 | 20 | count(issues) == 0 21 | } 22 | 23 | mock_no_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {}) 24 | 25 | test_deny_no_resources_passed if { 26 | issues := deny_no_resources with terraform.resources as mock_no_resources 27 | 28 | count(issues) == 1 29 | issue := issues[_] 30 | issue.msg == "resources should be declared" 31 | } 32 | 33 | test_deny_no_resources_failed if { 34 | issues := deny_no_resources with terraform.resources as mock_no_resources 35 | 36 | count(issues) == 0 37 | } 38 | -------------------------------------------------------------------------------- /integration/resources/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_no_resources", 6 | "severity": "error", 7 | "link": "policies/main.rego:12" 8 | }, 9 | "message": "resources should be declared", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 1, 14 | "column": 1 15 | }, 16 | "end": { 17 | "line": 1, 18 | "column": 1 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/resources/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_all_resources_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:17" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_test_deny_no_resources_failed", 26 | "severity": "error", 27 | "link": "policies/main_test.rego:33" 28 | }, 29 | "message": "test failed", 30 | "range": { 31 | "filename": "", 32 | "start": { 33 | "line": 0, 34 | "column": 0 35 | }, 36 | "end": { 37 | "line": 0, 38 | "column": 0 39 | } 40 | }, 41 | "callers": [] 42 | } 43 | ], 44 | "errors": [] 45 | } 46 | -------------------------------------------------------------------------------- /integration/settings/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/settings/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | cloud { 3 | hostname = "app.terraform.io" 4 | } 5 | } 6 | 7 | terraform { 8 | cloud {} 9 | } 10 | -------------------------------------------------------------------------------- /integration/settings/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_default_hostname contains issue if { 6 | settings := terraform.settings({"cloud": {"hostname": "string"}}, {}) 7 | hostname := settings[_].config.cloud[_].config.hostname 8 | 9 | hostname.value == "app.terraform.io" 10 | 11 | issue := tflint.issue("default hostname should be omitted", hostname.range) 12 | } 13 | -------------------------------------------------------------------------------- /integration/settings/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_settings(schema, options) := terraform.mock_settings(schema, options, {"main.tf": ` 6 | terraform { 7 | cloud { 8 | hostname = "app.terraform.io" 9 | } 10 | }`}) 11 | 12 | test_deny_default_hostname_passed if { 13 | issues := deny_default_hostname with terraform.settings as mock_settings 14 | 15 | count(issues) == 1 16 | issue := issues[_] 17 | issue.msg == "default hostname should be omitted" 18 | } 19 | 20 | test_deny_default_hostname_failed if { 21 | issues := deny_default_hostname with terraform.settings as mock_settings 22 | 23 | count(issues) == 0 24 | } 25 | -------------------------------------------------------------------------------- /integration/settings/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_default_hostname", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "default hostname should be omitted", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 3, 14 | "column": 16 15 | }, 16 | "end": { 17 | "line": 3, 18 | "column": 34 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/settings/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_default_hostname_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:20" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/tagged/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/tagged/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "valid" { 2 | tags = { 3 | "Environment" = "production" 4 | } 5 | } 6 | 7 | resource "aws_instance" "invalid" { 8 | tags = { 9 | "production" = true 10 | } 11 | } 12 | 13 | resource "aws_instance" "not_tagged" { 14 | instance_type = "t2.micro" 15 | } 16 | 17 | resource "aws_instance" "null" { 18 | tags = null 19 | } 20 | -------------------------------------------------------------------------------- /integration/tagged/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | is_not_tagged(config) if { 6 | is_null(config.tags.value) 7 | } 8 | 9 | is_not_tagged(config) if { 10 | not is_null(config.tags.value) 11 | not "Environment" in object.keys(config.tags.value) 12 | } 13 | 14 | is_not_tagged(config) if { 15 | not "tags" in object.keys(config) 16 | } 17 | 18 | deny_not_tagged_instance contains issue if { 19 | resources := terraform.resources("aws_instance", {"tags": "map(string)"}, {}) 20 | resource := resources[_] 21 | 22 | is_not_tagged(resource.config) 23 | 24 | issue := tflint.issue("instance should be tagged with Environment", resource.decl_range) 25 | } 26 | -------------------------------------------------------------------------------- /integration/tagged/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_resources_not_tagged(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "main" { 7 | tags = { 8 | "production" = true 9 | } 10 | }`}) 11 | 12 | test_deny_not_tagged_instance_passed if { 13 | issues := deny_not_tagged_instance with terraform.resources as mock_resources_not_tagged 14 | 15 | count(issues) == 1 16 | issue := issues[_] 17 | issue.msg == "instance should be tagged with Environment" 18 | } 19 | 20 | test_deny_not_tagged_instance_failed if { 21 | issues := deny_not_tagged_instance with terraform.resources as mock_resources_not_tagged 22 | 23 | count(issues) == 0 24 | } 25 | 26 | mock_resources_no_tags(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 27 | resource "aws_instance" "main" { 28 | }`}) 29 | 30 | test_deny_not_tagged_instance_without_tags_passed if { 31 | issues := deny_not_tagged_instance with terraform.resources as mock_resources_no_tags 32 | 33 | count(issues) == 1 34 | issue := issues[_] 35 | issue.msg == "instance should be tagged with Environment" 36 | } 37 | 38 | test_deny_not_tagged_instance_without_tags_failed if { 39 | issues := deny_not_tagged_instance with terraform.resources as mock_resources_no_tags 40 | 41 | count(issues) == 0 42 | } 43 | 44 | mock_resources_null_tags(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 45 | resource "aws_instance" "main" { 46 | tags = null 47 | }`}) 48 | 49 | test_deny_not_tagged_instance_null_tags_passed if { 50 | issues := deny_not_tagged_instance with terraform.resources as mock_resources_null_tags 51 | 52 | count(issues) == 1 53 | issue := issues[_] 54 | issue.msg == "instance should be tagged with Environment" 55 | } 56 | 57 | test_deny_not_tagged_instance_null_tags_failed if { 58 | issues := deny_not_tagged_instance with terraform.resources as mock_resources_null_tags 59 | 60 | count(issues) == 0 61 | } 62 | -------------------------------------------------------------------------------- /integration/tagged/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_not_tagged_instance", 6 | "severity": "error", 7 | "link": "policies/main.rego:18" 8 | }, 9 | "message": "instance should be tagged with Environment", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 7, 14 | "column": 1 15 | }, 16 | "end": { 17 | "line": 7, 18 | "column": 34 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_deny_not_tagged_instance", 26 | "severity": "error", 27 | "link": "policies/main.rego:18" 28 | }, 29 | "message": "instance should be tagged with Environment", 30 | "range": { 31 | "filename": "main.tf", 32 | "start": { 33 | "line": 13, 34 | "column": 1 35 | }, 36 | "end": { 37 | "line": 13, 38 | "column": 37 39 | } 40 | }, 41 | "callers": [] 42 | }, 43 | { 44 | "rule": { 45 | "name": "opa_deny_not_tagged_instance", 46 | "severity": "error", 47 | "link": "policies/main.rego:18" 48 | }, 49 | "message": "instance should be tagged with Environment", 50 | "range": { 51 | "filename": "main.tf", 52 | "start": { 53 | "line": 17, 54 | "column": 1 55 | }, 56 | "end": { 57 | "line": 17, 58 | "column": 31 59 | } 60 | }, 61 | "callers": [] 62 | } 63 | ], 64 | "errors": [] 65 | } 66 | -------------------------------------------------------------------------------- /integration/tagged/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_not_tagged_instance_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:20" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_test_deny_not_tagged_instance_without_tags_failed", 26 | "severity": "error", 27 | "link": "policies/main_test.rego:38" 28 | }, 29 | "message": "test failed", 30 | "range": { 31 | "filename": "", 32 | "start": { 33 | "line": 0, 34 | "column": 0 35 | }, 36 | "end": { 37 | "line": 0, 38 | "column": 0 39 | } 40 | }, 41 | "callers": [] 42 | }, 43 | { 44 | "rule": { 45 | "name": "opa_test_deny_not_tagged_instance_null_tags_failed", 46 | "severity": "error", 47 | "link": "policies/main_test.rego:57" 48 | }, 49 | "message": "test failed", 50 | "range": { 51 | "filename": "", 52 | "start": { 53 | "line": 0, 54 | "column": 0 55 | }, 56 | "end": { 57 | "line": 0, 58 | "column": 0 59 | } 60 | }, 61 | "callers": [] 62 | } 63 | ], 64 | "errors": [] 65 | } 66 | -------------------------------------------------------------------------------- /integration/variables/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/variables/main.tf: -------------------------------------------------------------------------------- 1 | variable "valid" { 2 | description = "this is valid variable" 3 | } 4 | 5 | variable "invalid" { 6 | description = "" 7 | } 8 | -------------------------------------------------------------------------------- /integration/variables/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_empty_description contains issue if { 6 | vars := terraform.variables({"description": "string"}, {}) 7 | description := vars[_].config.description 8 | 9 | description.value == "" 10 | 11 | issue := tflint.issue("empty description is not allowed", description.range) 12 | } 13 | -------------------------------------------------------------------------------- /integration/variables/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_variables(schema, options) := terraform.mock_variables(schema, options, {"main.tf": ` 6 | variable "foo" { 7 | description = "" 8 | }`}) 9 | 10 | test_deny_empty_description_passed if { 11 | issues := deny_empty_description with terraform.variables as mock_variables 12 | 13 | count(issues) == 1 14 | issue := issues[_] 15 | issue.msg == "empty description is not allowed" 16 | } 17 | 18 | test_deny_empty_description_failed if { 19 | issues := deny_empty_description with terraform.variables as mock_variables 20 | 21 | count(issues) == 0 22 | } 23 | -------------------------------------------------------------------------------- /integration/variables/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_empty_description", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "empty description is not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 6, 14 | "column": 17 15 | }, 16 | "end": { 17 | "line": 6, 18 | "column": 19 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/variables/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_empty_description_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:18" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | } 23 | ], 24 | "errors": [] 25 | } 26 | -------------------------------------------------------------------------------- /integration/volume_size/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/volume_size/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "valid" { 2 | ebs_block_device { 3 | volume_size = 20 4 | } 5 | } 6 | 7 | resource "aws_instance" "invalid" { 8 | ebs_block_device { 9 | volume_size = 50 10 | } 11 | } 12 | 13 | resource "aws_instance" "valid_string" { 14 | ebs_block_device { 15 | volume_size = "20" 16 | } 17 | } 18 | 19 | resource "aws_instance" "invalid_string" { 20 | ebs_block_device { 21 | volume_size = "50" 22 | } 23 | } 24 | 25 | resource "aws_instance" "valid_float" { 26 | ebs_block_device { 27 | volume_size = 20.5 28 | } 29 | } 30 | 31 | resource "aws_instance" "invalid_float" { 32 | ebs_block_device { 33 | volume_size = 30.5 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /integration/volume_size/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_large_volume contains issue if { 6 | resources := terraform.resources("aws_instance", {"ebs_block_device": {"volume_size": "number"}}, {}) 7 | volume_size := resources[_].config.ebs_block_device[_].config.volume_size 8 | volume_size.value > 30 9 | 10 | issue := tflint.issue("volume size should be 30GB or less", volume_size.range) 11 | } 12 | -------------------------------------------------------------------------------- /integration/volume_size/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_resources_50gb(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "main" { 7 | ebs_block_device { 8 | volume_size = 50 9 | } 10 | }`}) 11 | 12 | test_deny_large_volume_passed if { 13 | issues := deny_large_volume with terraform.resources as mock_resources_50gb 14 | 15 | count(issues) == 1 16 | issue := issues[_] 17 | issue.msg == "volume size should be 30GB or less" 18 | } 19 | 20 | test_deny_large_volume_failed if { 21 | issues := deny_large_volume with terraform.resources as mock_resources_50gb 22 | 23 | count(issues) == 0 24 | } 25 | 26 | mock_resources_50gb_string(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 27 | resource "aws_instance" "main" { 28 | ebs_block_device { 29 | volume_size = "50" 30 | } 31 | }`}) 32 | 33 | test_deny_large_volume_string_passed if { 34 | issues := deny_large_volume with terraform.resources as mock_resources_50gb_string 35 | 36 | count(issues) == 1 37 | issue := issues[_] 38 | issue.msg == "volume size should be 30GB or less" 39 | } 40 | 41 | test_deny_large_volume_string_failed if { 42 | issues := deny_large_volume with terraform.resources as mock_resources_50gb_string 43 | 44 | count(issues) == 0 45 | } 46 | 47 | mock_resources_30_5gb(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 48 | resource "aws_instance" "main" { 49 | ebs_block_device { 50 | volume_size = 30.5 51 | } 52 | }`}) 53 | 54 | test_deny_large_volume_float_passed if { 55 | issues := deny_large_volume with terraform.resources as mock_resources_30_5gb 56 | 57 | count(issues) == 1 58 | issue := issues[_] 59 | issue.msg == "volume size should be 30GB or less" 60 | } 61 | 62 | test_deny_large_volume_float_failed if { 63 | issues := deny_large_volume with terraform.resources as mock_resources_30_5gb 64 | 65 | count(issues) == 0 66 | } 67 | -------------------------------------------------------------------------------- /integration/volume_size/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_deny_large_volume", 6 | "severity": "error", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "volume size should be 30GB or less", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 9, 14 | "column": 19 15 | }, 16 | "end": { 17 | "line": 9, 18 | "column": 21 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_deny_large_volume", 26 | "severity": "error", 27 | "link": "policies/main.rego:5" 28 | }, 29 | "message": "volume size should be 30GB or less", 30 | "range": { 31 | "filename": "main.tf", 32 | "start": { 33 | "line": 21, 34 | "column": 19 35 | }, 36 | "end": { 37 | "line": 21, 38 | "column": 23 39 | } 40 | }, 41 | "callers": [] 42 | }, 43 | { 44 | "rule": { 45 | "name": "opa_deny_large_volume", 46 | "severity": "error", 47 | "link": "policies/main.rego:5" 48 | }, 49 | "message": "volume size should be 30GB or less", 50 | "range": { 51 | "filename": "main.tf", 52 | "start": { 53 | "line": 33, 54 | "column": 19 55 | }, 56 | "end": { 57 | "line": 33, 58 | "column": 23 59 | } 60 | }, 61 | "callers": [] 62 | } 63 | ], 64 | "errors": [] 65 | } 66 | -------------------------------------------------------------------------------- /integration/volume_size/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_deny_large_volume_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:20" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_test_deny_large_volume_string_failed", 26 | "severity": "error", 27 | "link": "policies/main_test.rego:41" 28 | }, 29 | "message": "test failed", 30 | "range": { 31 | "filename": "", 32 | "start": { 33 | "line": 0, 34 | "column": 0 35 | }, 36 | "end": { 37 | "line": 0, 38 | "column": 0 39 | } 40 | }, 41 | "callers": [] 42 | }, 43 | { 44 | "rule": { 45 | "name": "opa_test_deny_large_volume_float_failed", 46 | "severity": "error", 47 | "link": "policies/main_test.rego:62" 48 | }, 49 | "message": "test failed", 50 | "range": { 51 | "filename": "", 52 | "start": { 53 | "line": 0, 54 | "column": 0 55 | }, 56 | "end": { 57 | "line": 0, 58 | "column": 0 59 | } 60 | }, 61 | "callers": [] 62 | } 63 | ], 64 | "errors": [] 65 | } 66 | -------------------------------------------------------------------------------- /integration/volume_type/.tflint.hcl: -------------------------------------------------------------------------------- 1 | plugin "terraform" { 2 | enabled = false 3 | } 4 | 5 | plugin "opa" { 6 | enabled = true 7 | 8 | policy_dir = "policies" 9 | } 10 | -------------------------------------------------------------------------------- /integration/volume_type/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_instance" "valid" { 2 | ebs_block_device { 3 | volume_type = "gp2" 4 | } 5 | } 6 | 7 | resource "aws_instance" "invalid" { 8 | ebs_block_device { 9 | volume_type = "gp3" 10 | } 11 | } 12 | 13 | resource "aws_instance" "dynamic_invalid" { 14 | dynamic "ebs_block_device" { 15 | for_each = ["gp3"] 16 | 17 | content { 18 | volume_type = ebs_block_device.value 19 | } 20 | } 21 | } 22 | 23 | resource "aws_instance" "not_created" { 24 | count = 0 25 | 26 | ebs_block_device { 27 | volume_type = "gp3" 28 | } 29 | } 30 | 31 | variable "unknown" {} 32 | 33 | resource "aws_instance" "dynamic_unknown" { 34 | dynamic "ebs_block_device" { 35 | for_each = var.unknown 36 | 37 | content { 38 | volume_type = ebs_block_device.value 39 | } 40 | } 41 | } 42 | 43 | resource "aws_instance" "unknown" { 44 | count = var.unknown 45 | 46 | ebs_block_device { 47 | volume_type = "gp3" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /integration/volume_type/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | warn_gp3_volume contains issue if { 6 | resources := terraform.resources("aws_instance", {"count": "number"}, {"expand_mode": "none"}) 7 | count := resources[_].config.count 8 | 9 | count.unknown == true 10 | 11 | issue := tflint.issue("unknown resource found", count.range) 12 | } 13 | 14 | warn_gp3_volume contains issue if { 15 | resources := terraform.resources("aws_instance", {"dynamic": {"__labels": ["name"], "for_each": "any"}}, {"expand_mode": "none"}) 16 | for_each := resources[_].config.dynamic[_].config.for_each 17 | 18 | for_each.unknown == true 19 | 20 | issue := tflint.issue("unknown block found", for_each.range) 21 | } 22 | 23 | warn_gp3_volume contains issue if { 24 | resources := terraform.resources("aws_instance", {"ebs_block_device": {"volume_type": "string"}}, {}) 25 | volume_type := resources[_].config.ebs_block_device[_].config.volume_type 26 | 27 | volume_type.value == "gp3" 28 | 29 | issue := tflint.issue("gp3 is not allowed", volume_type.range) 30 | } 31 | -------------------------------------------------------------------------------- /integration/volume_type/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_resources_gp3(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "main" { 7 | ebs_block_device { 8 | volume_type = "gp3" 9 | } 10 | }`}) 11 | 12 | test_warn_gp3_volume_passed if { 13 | issues := warn_gp3_volume with terraform.resources as mock_resources_gp3 14 | 15 | count(issues) == 1 16 | issue := issues[_] 17 | issue.msg == "gp3 is not allowed" 18 | } 19 | 20 | test_warn_gp3_volume_failed if { 21 | issues := warn_gp3_volume with terraform.resources as mock_resources_gp3 22 | 23 | count(issues) == 0 24 | } 25 | 26 | mock_resources_unknown_dynamic(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 27 | variable "unknown" {} 28 | 29 | resource "aws_instance" "main" { 30 | dynamic "ebs_block_device" { 31 | for_each = var.unknown 32 | 33 | content { 34 | volume_type = ebs_block_device.value 35 | } 36 | } 37 | }`}) 38 | 39 | test_warn_gp3_volume_unknown_dynamic_passed if { 40 | issues := warn_gp3_volume with terraform.resources as mock_resources_unknown_dynamic 41 | 42 | count(issues) == 1 43 | issue := issues[_] 44 | issue.msg == "unknown block found" 45 | } 46 | 47 | test_warn_gp3_volume_unknown_dynamic_failed if { 48 | issues := warn_gp3_volume with terraform.resources as mock_resources_unknown_dynamic 49 | 50 | count(issues) == 0 51 | } 52 | -------------------------------------------------------------------------------- /integration/volume_type/result.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_warn_gp3_volume", 6 | "severity": "warning", 7 | "link": "policies/main.rego:5" 8 | }, 9 | "message": "gp3 is not allowed", 10 | "range": { 11 | "filename": "main.tf", 12 | "start": { 13 | "line": 9, 14 | "column": 19 15 | }, 16 | "end": { 17 | "line": 9, 18 | "column": 24 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_warn_gp3_volume", 26 | "severity": "warning", 27 | "link": "policies/main.rego:5" 28 | }, 29 | "message": "gp3 is not allowed", 30 | "range": { 31 | "filename": "main.tf", 32 | "start": { 33 | "line": 18, 34 | "column": 21 35 | }, 36 | "end": { 37 | "line": 18, 38 | "column": 43 39 | } 40 | }, 41 | "callers": [] 42 | }, 43 | { 44 | "rule": { 45 | "name": "opa_warn_gp3_volume", 46 | "severity": "warning", 47 | "link": "policies/main.rego:5" 48 | }, 49 | "message": "unknown block found", 50 | "range": { 51 | "filename": "main.tf", 52 | "start": { 53 | "line": 35, 54 | "column": 16 55 | }, 56 | "end": { 57 | "line": 35, 58 | "column": 27 59 | } 60 | }, 61 | "callers": [] 62 | }, 63 | { 64 | "rule": { 65 | "name": "opa_warn_gp3_volume", 66 | "severity": "warning", 67 | "link": "policies/main.rego:5" 68 | }, 69 | "message": "unknown resource found", 70 | "range": { 71 | "filename": "main.tf", 72 | "start": { 73 | "line": 44, 74 | "column": 11 75 | }, 76 | "end": { 77 | "line": 44, 78 | "column": 22 79 | } 80 | }, 81 | "callers": [] 82 | } 83 | ], 84 | "errors": [] 85 | } 86 | -------------------------------------------------------------------------------- /integration/volume_type/result_test.json: -------------------------------------------------------------------------------- 1 | { 2 | "issues": [ 3 | { 4 | "rule": { 5 | "name": "opa_test_warn_gp3_volume_failed", 6 | "severity": "error", 7 | "link": "policies/main_test.rego:20" 8 | }, 9 | "message": "test failed", 10 | "range": { 11 | "filename": "", 12 | "start": { 13 | "line": 0, 14 | "column": 0 15 | }, 16 | "end": { 17 | "line": 0, 18 | "column": 0 19 | } 20 | }, 21 | "callers": [] 22 | }, 23 | { 24 | "rule": { 25 | "name": "opa_test_warn_gp3_volume_unknown_dynamic_failed", 26 | "severity": "error", 27 | "link": "policies/main_test.rego:47" 28 | }, 29 | "message": "test failed", 30 | "range": { 31 | "filename": "", 32 | "start": { 33 | "line": 0, 34 | "column": 0 35 | }, 36 | "end": { 37 | "line": 0, 38 | "column": 0 39 | } 40 | }, 41 | "callers": [] 42 | } 43 | ], 44 | "errors": [] 45 | } 46 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/terraform-linters/tflint-plugin-sdk/plugin" 5 | "github.com/terraform-linters/tflint-plugin-sdk/tflint" 6 | "github.com/terraform-linters/tflint-ruleset-opa/opa" 7 | ) 8 | 9 | func main() { 10 | plugin.Serve(&plugin.ServeOpts{ 11 | RuleSet: &opa.RuleSet{ 12 | BuiltinRuleSet: tflint.BuiltinRuleSet{ 13 | Name: "opa", 14 | Version: "0.8.0", 15 | Constraint: ">= 0.43.0", 16 | }, 17 | }, 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /opa/config.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/mitchellh/go-homedir" 7 | ) 8 | 9 | // Config is the configuration for the ruleset. 10 | type Config struct { 11 | PolicyDir string `hclext:"policy_dir,optional"` 12 | } 13 | 14 | var ( 15 | policyRoot = "~/.tflint.d/policies" 16 | localPolicyRoot = "./.tflint.d/policies" 17 | ) 18 | 19 | // policyDir returns the base policy directory. 20 | // Adopted with the following priorities: 21 | // 22 | // 1. `policy_dir` in a config file 23 | // 2. `TFLINT_OPA_POLICY_DIR` environment variable 24 | // 3. Current directory (./.tflint.d/policies) 25 | // 4. Home directory (~/.tflint.d/policies) 26 | // 27 | // If the environment variable is set, other directories will not be considered, 28 | // but if the current directory does not exist, it will fallback to the home directory. 29 | func (c *Config) policyDir() (string, error) { 30 | if c.PolicyDir != "" { 31 | return homedir.Expand(c.PolicyDir) 32 | } 33 | 34 | if dir := os.Getenv("TFLINT_OPA_POLICY_DIR"); dir != "" { 35 | return dir, nil 36 | } 37 | 38 | _, err := os.Stat(localPolicyRoot) 39 | if os.IsNotExist(err) { 40 | return policyRootDir() 41 | } 42 | 43 | return localPolicyRoot, err 44 | } 45 | 46 | func policyRootDir() (string, error) { 47 | dir, err := homedir.Expand(policyRoot) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | // Returning os.ErrNotExist allows checking to continue even if it doesn't exist 53 | _, err = os.Stat(dir) 54 | return dir, err 55 | } 56 | -------------------------------------------------------------------------------- /opa/config_test.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestPolicyDir(t *testing.T) { 11 | cwd, err := os.Getwd() 12 | if err != nil { 13 | t.Fatal(err) 14 | } 15 | 16 | tests := []struct { 17 | name string 18 | config *Config 19 | root string 20 | currentDir string 21 | env map[string]string 22 | want string 23 | err error 24 | }{ 25 | { 26 | name: "default (not exists)", 27 | config: &Config{}, 28 | root: filepath.Join(cwd, "test-fixtures", "config", "root-not-exists", ".tflint.d", "policies"), 29 | want: filepath.Join(cwd, "test-fixtures", "config", "root-not-exists", ".tflint.d", "policies"), 30 | err: os.ErrNotExist, 31 | }, 32 | { 33 | name: "default (exists)", 34 | config: &Config{}, 35 | root: filepath.Join(cwd, "test-fixtures", "config", "root-exists", ".tflint.d", "policies"), 36 | want: filepath.Join(cwd, "test-fixtures", "config", "root-exists", ".tflint.d", "policies"), 37 | }, 38 | { 39 | name: "local", 40 | config: &Config{}, 41 | currentDir: filepath.Join(cwd, "test-fixtures", "config", "local"), 42 | want: "./.tflint.d/policies", 43 | }, 44 | { 45 | name: "env", 46 | config: &Config{}, 47 | env: map[string]string{ 48 | "TFLINT_OPA_POLICY_DIR": "policies", 49 | }, 50 | want: "policies", 51 | }, 52 | { 53 | name: "config", 54 | config: &Config{PolicyDir: "config/policies"}, 55 | want: "config/policies", 56 | }, 57 | } 58 | 59 | for _, test := range tests { 60 | t.Run(test.name, func(t *testing.T) { 61 | if test.root != "" { 62 | original := policyRoot 63 | policyRoot = test.root 64 | defer func() { policyRoot = original }() 65 | } 66 | if test.currentDir != "" { 67 | os.Chdir(test.currentDir) 68 | defer os.Chdir(cwd) 69 | } 70 | for k, v := range test.env { 71 | t.Setenv(k, v) 72 | } 73 | 74 | got, err := test.config.policyDir() 75 | if err != nil { 76 | if errors.Is(err, test.err) { 77 | return 78 | } 79 | t.Fatal(err) 80 | } 81 | if err == nil && test.err != nil { 82 | t.Fatal("should return an error, but it does not") 83 | } 84 | 85 | if got != test.want { 86 | t.Fatalf("want: %s, got: %s", test.want, got) 87 | } 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /opa/engine.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "strings" 9 | 10 | "github.com/hashicorp/go-hclog" 11 | "github.com/hashicorp/hcl/v2" 12 | "github.com/open-policy-agent/opa/v1/ast" 13 | "github.com/open-policy-agent/opa/v1/loader" 14 | "github.com/open-policy-agent/opa/v1/rego" 15 | "github.com/open-policy-agent/opa/v1/storage" 16 | "github.com/open-policy-agent/opa/v1/tester" 17 | "github.com/open-policy-agent/opa/v1/topdown" 18 | "github.com/open-policy-agent/opa/v1/topdown/print" 19 | "github.com/open-policy-agent/opa/v1/version" 20 | "github.com/terraform-linters/tflint-plugin-sdk/logger" 21 | "github.com/terraform-linters/tflint-plugin-sdk/tflint" 22 | ) 23 | 24 | // Engine evaluates policies and returns issues. 25 | // In other words, this is a wrapper of rego.New(...).Eval(). 26 | type Engine struct { 27 | store storage.Store 28 | modules map[string]*ast.Module 29 | print print.Hook 30 | traceWriter io.Writer 31 | runtime *ast.Term 32 | } 33 | 34 | // NewEngine returns a new engine based on the policies loaded 35 | func NewEngine(ret *loader.Result) (*Engine, error) { 36 | store, err := ret.Store() 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | logWriter := logger.Logger().StandardWriter(&hclog.StandardLoggerOptions{ForceLevel: hclog.Debug}) 42 | printer := topdown.NewPrintHook(logWriter) 43 | 44 | // If TFLINT_OPA_TRACE is set, print traces to the debug log. 45 | var traceWriter io.Writer 46 | trace := os.Getenv("TFLINT_OPA_TRACE") 47 | if trace != "" && trace != "false" && trace != "0" { 48 | traceWriter = logWriter 49 | } 50 | 51 | return &Engine{ 52 | store: store, 53 | modules: ret.ParsedModules(), 54 | print: printer, 55 | traceWriter: traceWriter, 56 | runtime: runtime(), 57 | }, nil 58 | } 59 | 60 | // Issue is the result of the query. 61 | type Issue struct { 62 | Message string 63 | Range hcl.Range 64 | } 65 | 66 | // RunQuery executes a query referencing a rule and returns the generated 67 | // Set document as Result. 68 | // rego.ResultSet is parsed according to the following conventions: 69 | // 70 | // - All rules should be under the "tflint" package 71 | // - Rule should return a tflint.issue() 72 | // 73 | // Example: 74 | // 75 | // ``` 76 | // 77 | // deny_test[issue] { 78 | // [condition] 79 | // 80 | // issue := tflint.issue("not allowed", resource.decl_range) 81 | // } 82 | // 83 | // ``` 84 | func (e *Engine) RunQuery(rule *Rule, runner tflint.Runner) ([]*Issue, error) { 85 | traceEnabled := e.traceWriter != nil 86 | 87 | options := []func(*rego.Rego){ 88 | // All rules should be under the "tflint" package 89 | rego.Query(fmt.Sprintf("data.tflint.%s", rule.RegoName())), 90 | // Makes it possible to refer to the loaded YAML/JSON as the "data" document 91 | rego.Store(e.store), 92 | // Enable strict mode 93 | rego.Strict(true), 94 | // Enable strict-builtin-errors to return custom function errors immediately 95 | rego.StrictBuiltinErrors(true), 96 | // Enable print() to invoke logger.Debug() 97 | rego.EnablePrintStatements(true), 98 | rego.PrintHook(e.print), 99 | // Enable trace() if TFLINT_OPA_TRACE=true 100 | rego.Trace(traceEnabled), 101 | // Enable opa.runtime().env/version/commit 102 | rego.Runtime(e.runtime), 103 | } 104 | // Set policies 105 | for _, m := range e.modules { 106 | options = append(options, rego.ParsedModule(m)) 107 | } 108 | // Enable custom functions (e.g. terraform.resources) 109 | // Mock functions are usually not needed outside of testing, 110 | // but are provided for compilation. 111 | options = append(options, Functions(runner)...) 112 | options = append(options, MockFunctions()...) 113 | 114 | instance := rego.New(options...) 115 | rs, err := instance.Eval(context.Background()) 116 | if err != nil { 117 | return nil, err 118 | } 119 | 120 | if traceEnabled { 121 | rego.PrintTrace(e.traceWriter, instance) 122 | } 123 | 124 | var issues []*Issue 125 | for _, result := range rs { 126 | for _, expr := range result.Expressions { 127 | values, ok := expr.Value.([]any) 128 | if !ok { 129 | return nil, fmt.Errorf("issue is not set, got %T", expr.Value) 130 | } 131 | 132 | for _, value := range values { 133 | ret, err := jsonToIssue(value, "issue") 134 | if err != nil { 135 | return nil, err 136 | } 137 | issues = append(issues, ret) 138 | } 139 | } 140 | } 141 | 142 | return issues, err 143 | } 144 | 145 | // RunTest runs a policy test. The details are hidden inside open-policy-agent/opa/tester 146 | // and this is a wrapper of it. Test results are emitted as issues if failed or errored. 147 | // 148 | // A runner is provided, but in many cases the runner is never actually used, 149 | // as test runners are generated inside mock functions. See TesterMockFunctions for details. 150 | func (e *Engine) RunTest(rule *TestRule, runner tflint.Runner) ([]*Issue, error) { 151 | traceEnabled := e.traceWriter != nil 152 | 153 | testRunner := tester.NewRunner(). 154 | SetStore(e.store). 155 | CapturePrintOutput(true). 156 | EnableTracing(traceEnabled). 157 | SetRuntime(e.runtime). 158 | SetModules(e.modules). 159 | AddCustomBuiltins(append(TesterFunctions(runner), TesterMockFunctions()...)). 160 | Filter(rule.RegoName()) 161 | 162 | ch, err := testRunner.RunTests(context.Background(), nil) 163 | if err != nil { 164 | return nil, err 165 | } 166 | 167 | var issues []*Issue 168 | for ret := range ch { 169 | if ret.Error != nil { 170 | // Location is not included as it is not an issue for HCL. 171 | issues = append(issues, &Issue{ 172 | Message: fmt.Sprintf("test errored: %s", ret.Error), 173 | }) 174 | continue 175 | } 176 | 177 | if ret.Output != nil { 178 | logger.Debug(string(ret.Output)) 179 | } 180 | if traceEnabled { 181 | topdown.PrettyTrace(e.traceWriter, ret.Trace) 182 | } 183 | 184 | if ret.Fail { 185 | issues = append(issues, &Issue{ 186 | Message: "test failed", 187 | }) 188 | } 189 | } 190 | 191 | return issues, nil 192 | } 193 | 194 | func runtime() *ast.Term { 195 | env := ast.NewObject() 196 | for _, pair := range os.Environ() { 197 | parts := strings.SplitN(pair, "=", 2) 198 | if len(parts) == 1 { 199 | env.Insert(ast.StringTerm(parts[0]), ast.NullTerm()) 200 | } else if len(parts) > 1 { 201 | env.Insert(ast.StringTerm(parts[0]), ast.StringTerm(parts[1])) 202 | } 203 | } 204 | 205 | obj := ast.NewObject() 206 | obj.Insert(ast.StringTerm("env"), ast.NewTerm(env)) 207 | obj.Insert(ast.StringTerm("version"), ast.StringTerm(version.Version)) 208 | obj.Insert(ast.StringTerm("commit"), ast.StringTerm(version.Vcs)) 209 | 210 | return ast.NewTerm(obj) 211 | } 212 | -------------------------------------------------------------------------------- /opa/engine_test.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/google/go-cmp/cmp/cmpopts" 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/liamg/memoryfs" 11 | "github.com/open-policy-agent/opa/v1/loader" 12 | "github.com/open-policy-agent/opa/v1/version" 13 | ) 14 | 15 | func TestRunQuery(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | policies map[string]string 19 | config map[string]string 20 | want []*Issue 21 | err string 22 | }{ 23 | { 24 | name: "simple policy", 25 | policies: map[string]string{ 26 | "main.rego": ` 27 | package tflint 28 | 29 | import rego.v1 30 | 31 | deny_test contains issue if { 32 | issue := tflint.issue("example issue", terraform.module_range()) 33 | }`, 34 | }, 35 | want: []*Issue{{Message: "example issue", Range: hcl.Range{Filename: "main.tf", Start: hcl.InitialPos, End: hcl.InitialPos}}}, 36 | }, 37 | { 38 | name: "store data", 39 | policies: map[string]string{ 40 | "main.rego": ` 41 | package tflint 42 | 43 | import rego.v1 44 | 45 | deny_test contains issue if { 46 | issue := tflint.issue(sprintf("data.foo is %s", [data.foo]), terraform.module_range()) 47 | }`, 48 | "data.yaml": `foo: bar`, 49 | }, 50 | want: []*Issue{{Message: "data.foo is bar", Range: hcl.Range{Filename: "main.tf", Start: hcl.InitialPos, End: hcl.InitialPos}}}, 51 | }, 52 | { 53 | name: "terraform functions", 54 | policies: map[string]string{ 55 | "main.rego": ` 56 | package tflint 57 | 58 | import rego.v1 59 | 60 | deny_test contains issue if { 61 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 62 | instance_type := resources[_].config.instance_type 63 | 64 | instance_type.value != "t2.micro" 65 | 66 | issue := tflint.issue("t2.micro is only allowed", instance_type.range) 67 | }`, 68 | }, 69 | config: map[string]string{ 70 | "main.tf": ` 71 | resource "aws_instance" "main" { 72 | instance_type = "t1.micro" 73 | }`, 74 | }, 75 | want: []*Issue{{Message: "t2.micro is only allowed", Range: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 3}, End: hcl.Pos{Line: 3}}}}, 76 | }, 77 | { 78 | name: "runtime", 79 | policies: map[string]string{ 80 | "main.rego": ` 81 | package tflint 82 | 83 | import rego.v1 84 | 85 | deny_test contains issue if { 86 | issue := tflint.issue(sprintf("OPA version: %s", [opa.runtime().version]), terraform.module_range()) 87 | }`, 88 | }, 89 | want: []*Issue{{Message: fmt.Sprintf("OPA version: %s", version.Version), Range: hcl.Range{Filename: "main.tf", Start: hcl.InitialPos, End: hcl.InitialPos}}}, 90 | }, 91 | { 92 | name: "strict mode", 93 | policies: map[string]string{ 94 | "main.rego": ` 95 | package tflint 96 | 97 | import rego.v1 98 | 99 | deny_test contains issue if { 100 | unused := "foo" 101 | issue := tflint.issue("example issue", terraform.module_range()) 102 | }`, 103 | }, 104 | err: "1 error occurred: main.rego:7: rego_compile_error: assigned var unused unused", 105 | }, 106 | { 107 | name: "builtin errors", 108 | policies: map[string]string{ 109 | "main.rego": ` 110 | package tflint 111 | 112 | import rego.v1 113 | 114 | deny_test contains issue if { 115 | resources := terraform.resources("*", {}, {"unknown": "option"}) 116 | resource := resources[_] 117 | issue := tflint.issue("example issue", resource.decl_range) 118 | }`, 119 | }, 120 | err: "main.rego:7: eval_builtin_error: terraform.resources: unknown option: unknown", 121 | }, 122 | { 123 | name: "invalid issue", 124 | policies: map[string]string{ 125 | "main.rego": ` 126 | package tflint 127 | 128 | import rego.v1 129 | 130 | deny_test if { 131 | "foo" == "foo" 132 | }`, 133 | }, 134 | err: "issue is not set, got bool", 135 | }, 136 | } 137 | 138 | for _, test := range tests { 139 | t.Run(test.name, func(t *testing.T) { 140 | fs := memoryfs.New() 141 | for path, content := range test.policies { 142 | fs.WriteFile(path, []byte(content), 0o644) 143 | } 144 | 145 | ret, err := loader.NewFileLoader().WithFS(fs).Filtered([]string{"."}, nil) 146 | if err != nil { 147 | t.Fatal(err) 148 | } 149 | 150 | engine, err := NewEngine(ret) 151 | if err != nil { 152 | t.Fatal(err) 153 | } 154 | 155 | runner, diags := NewTestRunner(test.config) 156 | if diags.HasErrors() { 157 | t.Fatal(diags) 158 | } 159 | 160 | got, err := engine.RunQuery(&Rule{regoName: "deny_test"}, runner) 161 | if err != nil { 162 | if err.Error() != test.err { 163 | t.Fatalf(`expect "%s", but got "%s"`, test.err, err.Error()) 164 | } 165 | return 166 | } 167 | if err == nil && test.err != "" { 168 | t.Fatal("should return an error, but it does not") 169 | } 170 | 171 | opt := cmpopts.IgnoreFields(hcl.Pos{}, "Column", "Byte") 172 | if diff := cmp.Diff(test.want, got, opt); diff != "" { 173 | t.Error(diff) 174 | } 175 | }) 176 | } 177 | } 178 | 179 | func TestRunTest(t *testing.T) { 180 | tests := []struct { 181 | name string 182 | policies map[string]string 183 | want []*Issue 184 | err string 185 | }{ 186 | { 187 | name: "simple policy", 188 | policies: map[string]string{ 189 | "main_test.rego": ` 190 | package tflint 191 | 192 | import rego.v1 193 | 194 | test_deny if { 195 | "foo" == "bar" 196 | }`, 197 | }, 198 | want: []*Issue{{Message: "test failed"}}, 199 | }, 200 | { 201 | name: "store data", 202 | policies: map[string]string{ 203 | "main_test.rego": ` 204 | package tflint 205 | 206 | import rego.v1 207 | 208 | test_deny if { 209 | "foo" == data.foo 210 | }`, 211 | "data.yaml": `foo: bar`, 212 | }, 213 | want: []*Issue{{Message: "test failed"}}, 214 | }, 215 | { 216 | name: "terraform functions", 217 | policies: map[string]string{ 218 | "main.rego": ` 219 | package tflint 220 | 221 | import rego.v1 222 | 223 | deny_test contains issue if { 224 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 225 | instance_type := resources[_].config.instance_type 226 | 227 | instance_type.value != "t2.micro" 228 | 229 | issue := tflint.issue("t2.micro is only allowed", instance_type.range) 230 | }`, 231 | "main_test.rego": ` 232 | package tflint 233 | 234 | import rego.v1 235 | 236 | mock_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` + "`" + ` 237 | resource "aws_instance" "main" { 238 | instance_type = "t1.micro" 239 | }` + "`" + `}) 240 | 241 | test_deny if { 242 | count(deny_test) == 0 with terraform.resources as mock_resources 243 | } 244 | `, 245 | }, 246 | want: []*Issue{{Message: "test failed"}}, 247 | }, 248 | { 249 | name: "runtime", 250 | policies: map[string]string{ 251 | "main_test.rego": ` 252 | package tflint 253 | 254 | import rego.v1 255 | 256 | test_deny if { 257 | "foo" == opa.runtime().version 258 | }`, 259 | }, 260 | want: []*Issue{{Message: "test failed"}}, 261 | }, 262 | } 263 | 264 | for _, test := range tests { 265 | t.Run(test.name, func(t *testing.T) { 266 | fs := memoryfs.New() 267 | for path, content := range test.policies { 268 | fs.WriteFile(path, []byte(content), 0o644) 269 | } 270 | 271 | ret, err := loader.NewFileLoader().WithFS(fs).Filtered([]string{"."}, nil) 272 | if err != nil { 273 | t.Fatal(err) 274 | } 275 | 276 | engine, err := NewEngine(ret) 277 | if err != nil { 278 | t.Fatal(err) 279 | } 280 | 281 | runner, diags := NewTestRunner(map[string]string{}) 282 | if diags.HasErrors() { 283 | t.Fatal(diags) 284 | } 285 | 286 | got, err := engine.RunTest(&TestRule{regoName: "test_deny"}, runner) 287 | if err != nil { 288 | if err.Error() != test.err { 289 | t.Fatalf(`expect "%s", but got "%s"`, test.err, err.Error()) 290 | } 291 | return 292 | } 293 | if err == nil && test.err != "" { 294 | t.Fatal("should return an error, but it does not") 295 | } 296 | 297 | opt := cmpopts.IgnoreFields(hcl.Pos{}, "Column", "Byte") 298 | if diff := cmp.Diff(test.want, got, opt); diff != "" { 299 | t.Error(diff) 300 | } 301 | }) 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /opa/rule.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/open-policy-agent/opa/v1/ast" 8 | "github.com/open-policy-agent/opa/v1/ast/location" 9 | "github.com/terraform-linters/tflint-plugin-sdk/tflint" 10 | ) 11 | 12 | // Rule is a container for rules defined by Rego to satisfy tflint.Rule 13 | type Rule struct { 14 | tflint.DefaultRule 15 | 16 | engine *Engine 17 | 18 | name string 19 | regoName string 20 | severity tflint.Severity 21 | location *location.Location 22 | } 23 | 24 | var _ tflint.Rule = (*Rule)(nil) 25 | 26 | // NewRule returns a tflint.Rule from a Rego rule. 27 | // Note that the rule names in TFLint and in Rego are different. 28 | func NewRule(regoRule *ast.Rule, engine *Engine) *Rule { 29 | regoName := regoRule.Head.Name.String() 30 | 31 | // All valud rules must start with deny_/violation_/warn_/notice_ (e.g. deny_test) 32 | var severity tflint.Severity 33 | if strings.HasPrefix(regoName, "deny_") || strings.HasPrefix(regoName, "violation_") { 34 | severity = tflint.ERROR 35 | } else if strings.HasPrefix(regoName, "warn_") { 36 | severity = tflint.WARNING 37 | } else if strings.HasPrefix(regoName, "notice_") { 38 | severity = tflint.NOTICE 39 | } else { 40 | return nil 41 | } 42 | 43 | return &Rule{ 44 | engine: engine, 45 | // Add "opa_" to the rule name in TFLint (e.g. opa_deny_test) 46 | name: fmt.Sprintf("opa_%s", regoName), 47 | regoName: regoName, 48 | severity: severity, 49 | location: regoRule.Location, 50 | } 51 | } 52 | 53 | func (r *Rule) Name() string { 54 | return r.name 55 | } 56 | 57 | func (r *Rule) Enabled() bool { 58 | return true 59 | } 60 | 61 | func (r *Rule) Severity() tflint.Severity { 62 | return r.severity 63 | } 64 | 65 | func (r *Rule) Link() string { 66 | return r.location.String() 67 | } 68 | 69 | func (r *Rule) Check(runner tflint.Runner) error { 70 | issues, err := r.engine.RunQuery(r, runner) 71 | if err != nil { 72 | return err 73 | } 74 | 75 | for _, issue := range issues { 76 | if err := runner.EmitIssue(r, issue.Message, issue.Range); err != nil { 77 | return err 78 | } 79 | } 80 | 81 | return nil 82 | } 83 | 84 | func (r *Rule) RegoName() string { 85 | return r.regoName 86 | } 87 | -------------------------------------------------------------------------------- /opa/ruleset.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/open-policy-agent/opa/v1/loader" 8 | "github.com/terraform-linters/tflint-plugin-sdk/hclext" 9 | "github.com/terraform-linters/tflint-plugin-sdk/tflint" 10 | ) 11 | 12 | // RuleSet is the custom ruleset for OPA 13 | type RuleSet struct { 14 | tflint.BuiltinRuleSet 15 | 16 | globalConfig *tflint.Config 17 | config *Config 18 | } 19 | 20 | // ApplyGlobalConfig is normally not expected to be overridden, 21 | // but since rules are defined dynamically by Rego, it's inconvenient 22 | // to enable/disable rules here (Called in the order ApplyGlobalConfig 23 | // -> ApplyConfig). 24 | // So just save the config so that it can be applied after ApplyConfig. 25 | func (r *RuleSet) ApplyGlobalConfig(config *tflint.Config) error { 26 | r.globalConfig = config 27 | return nil 28 | } 29 | 30 | func (r *RuleSet) ConfigSchema() *hclext.BodySchema { 31 | r.config = &Config{} 32 | return hclext.ImpliedBodySchema(r.config) 33 | } 34 | 35 | // ApplyConfig loads policies and generates TFLint rules. 36 | // Run ApplyGlobalConfig after the rules are generated. 37 | func (r *RuleSet) ApplyConfig(body *hclext.BodyContent) error { 38 | diags := hclext.DecodeBody(body, nil, r.config) 39 | if diags.HasErrors() { 40 | return diags 41 | } 42 | 43 | policyDir, err := r.config.policyDir() 44 | if err != nil { 45 | // If you declare the directory in config or environment variables, 46 | // os.ErrNotExist will not be returned, resulting in load errors 47 | // later in the process. 48 | if os.IsNotExist(err) { 49 | return nil 50 | } 51 | return err 52 | } 53 | 54 | ret, err := loader.NewFileLoader().Filtered([]string{policyDir}, nil) 55 | if err != nil { 56 | return fmt.Errorf("failed to load policies; %w", err) 57 | } 58 | 59 | engine, err := NewEngine(ret) 60 | if err != nil { 61 | return fmt.Errorf("failed to initialize a policy engine; %w", err) 62 | } 63 | 64 | // If TFLINT_OPA_TEST is set, only run tests, not policy checks 65 | var testMode bool 66 | test := os.Getenv("TFLINT_OPA_TEST") 67 | if test != "" && test != "false" && test != "0" { 68 | testMode = true 69 | } 70 | 71 | regoRuleNames := map[string]bool{} 72 | for _, module := range ret.ParsedModules() { 73 | for _, regoRule := range module.Rules { 74 | ruleName := regoRule.Head.Name.String() 75 | if _, exists := regoRuleNames[ruleName]; exists { 76 | // Supports incremental rules, simply ignoring rules with the same name. 77 | continue 78 | } 79 | regoRuleNames[ruleName] = true 80 | 81 | if testMode { 82 | if rule := NewTestRule(regoRule, engine); rule != nil { 83 | r.Rules = append(r.Rules, rule) 84 | } 85 | } else { 86 | if rule := NewRule(regoRule, engine); rule != nil { 87 | r.Rules = append(r.Rules, rule) 88 | } 89 | } 90 | } 91 | } 92 | 93 | return r.BuiltinRuleSet.ApplyGlobalConfig(r.globalConfig) 94 | } 95 | -------------------------------------------------------------------------------- /opa/ruleset_test.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/terraform-linters/tflint-plugin-sdk/hclext" 11 | "github.com/terraform-linters/tflint-plugin-sdk/tflint" 12 | "github.com/zclconf/go-cty/cty" 13 | ) 14 | 15 | func TestApplyConfig(t *testing.T) { 16 | cwd, err := os.Getwd() 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | 21 | tests := []struct { 22 | name string 23 | config *hclext.BodyContent 24 | root string 25 | env map[string]string 26 | want []string 27 | err bool 28 | }{ 29 | { 30 | name: "rules exists", 31 | config: &hclext.BodyContent{ 32 | Attributes: hclext.Attributes{ 33 | "policy_dir": &hclext.Attribute{ 34 | Name: "policy_dir", 35 | Expr: hcl.StaticExpr(cty.StringVal(filepath.Join(cwd, "test-fixtures", "config", "root-exists", ".tflint.d", "policies")), hcl.Range{}), 36 | }, 37 | }, 38 | }, 39 | want: []string{"opa_deny_not_snake_case", "opa_deny_not_t2_micro"}, 40 | }, 41 | { 42 | name: "tests exists", 43 | config: &hclext.BodyContent{ 44 | Attributes: hclext.Attributes{ 45 | "policy_dir": &hclext.Attribute{ 46 | Name: "policy_dir", 47 | Expr: hcl.StaticExpr(cty.StringVal(filepath.Join(cwd, "test-fixtures", "config", "root-exists", ".tflint.d", "policies")), hcl.Range{}), 48 | }, 49 | }, 50 | }, 51 | env: map[string]string{ 52 | "TFLINT_OPA_TEST": "true", 53 | }, 54 | want: []string{"opa_test_deny_not_snake_case", "opa_test_not_deny_t2_micro"}, 55 | }, 56 | { 57 | name: "policy dir not exists, but the dir is default", 58 | root: filepath.Join(cwd, "test-fixtures", "config", "root-not-exists", ".tflint.d", "policies"), 59 | want: []string{}, 60 | }, 61 | { 62 | name: "policy dir does not exists", 63 | config: &hclext.BodyContent{ 64 | Attributes: hclext.Attributes{ 65 | "policy_dir": &hclext.Attribute{ 66 | Name: "policy_dir", 67 | Expr: hcl.StaticExpr(cty.StringVal(filepath.Join(cwd, "test-fixtures", "config", "root-not-exists", ".tflint.d", "policies")), hcl.Range{}), 68 | }, 69 | }, 70 | }, 71 | err: true, 72 | }, 73 | } 74 | 75 | original := policyRoot 76 | policyRoot = filepath.Join(cwd, "test-fixtures", "config", "root-exists", ".tflint.d", "policies") 77 | defer func() { policyRoot = original }() 78 | 79 | for _, test := range tests { 80 | t.Run(test.name, func(t *testing.T) { 81 | if test.root != "" { 82 | original := policyRoot 83 | policyRoot = test.root 84 | defer func() { policyRoot = original }() 85 | } 86 | for k, v := range test.env { 87 | t.Setenv(k, v) 88 | } 89 | 90 | ruleset := &RuleSet{config: &Config{}, globalConfig: &tflint.Config{}} 91 | err := ruleset.ApplyConfig(test.config) 92 | if err != nil { 93 | if test.err { 94 | return 95 | } 96 | t.Fatal(err) 97 | } 98 | if err == nil && test.err { 99 | t.Fatal("should return an error, but it does not") 100 | } 101 | 102 | got := make([]string, len(ruleset.Rules)) 103 | for i, r := range ruleset.Rules { 104 | got[i] = r.Name() 105 | } 106 | if diff := cmp.Diff(test.want, got); diff != "" { 107 | t.Error(diff) 108 | } 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /opa/test-fixtures/config/local/.tflint.d/policies/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terraform-linters/tflint-ruleset-opa/9d25b675c5b83a52824cf15c5fa3afd50f108bb7/opa/test-fixtures/config/local/.tflint.d/policies/.gitkeep -------------------------------------------------------------------------------- /opa/test-fixtures/config/root-exists/.tflint.d/policies/main.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | deny_not_snake_case contains issue if { 6 | resources := terraform.resources("*", {}, {}) 7 | not regex.match("^[a-z][a-z0-9]*(_[a-z0-9]+)*$", resources[i].name) 8 | 9 | issue := tflint.issue(sprintf("%s is not snake case", [resources[i].name]), resources[i].decl_range) 10 | } 11 | 12 | deny_not_t2_micro contains issue if { 13 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 14 | instance_type := resources[_].config.instance_type 15 | 16 | instance_type.unknown == true 17 | 18 | issue := tflint.issue("instance type is unknown", instance_type.range) 19 | } 20 | 21 | deny_not_t2_micro contains issue if { 22 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 23 | instance_type := resources[_].config.instance_type 24 | 25 | instance_type.value != "t2.micro" 26 | 27 | issue := tflint.issue("t2.micro is only allowed", instance_type.range) 28 | } 29 | -------------------------------------------------------------------------------- /opa/test-fixtures/config/root-exists/.tflint.d/policies/main_test.rego: -------------------------------------------------------------------------------- 1 | package tflint 2 | 3 | import rego.v1 4 | 5 | mock_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 6 | resource "aws_instance" "main-v2" {} 7 | `}) 8 | 9 | test_deny_not_snake_case if { 10 | issues := deny_not_snake_case with terraform.resources as mock_resources 11 | 12 | count(issues) == 1 13 | issue := issues[_] 14 | issue.msg == "main-v2 is not snake case" 15 | issue.range.start.line == 2 16 | } 17 | 18 | mock_resources_t1_micro(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` 19 | resource "aws_instance" "main" { 20 | instance_type = "t1.micro" 21 | }`}) 22 | 23 | test_not_deny_t2_micro if { 24 | issues := deny_not_t2_micro with terraform.resources as mock_resources_t1_micro 25 | 26 | count(issues) == 1 27 | issue := issues[_] 28 | issue.msg == "t2.micro is only allowed" 29 | issue.range.start.line == 2 30 | } 31 | -------------------------------------------------------------------------------- /opa/test-fixtures/config/root-not-exists/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/terraform-linters/tflint-ruleset-opa/9d25b675c5b83a52824cf15c5fa3afd50f108bb7/opa/test-fixtures/config/root-not-exists/.gitkeep -------------------------------------------------------------------------------- /opa/test_rule.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/open-policy-agent/opa/v1/ast" 8 | "github.com/open-policy-agent/opa/v1/ast/location" 9 | "github.com/terraform-linters/tflint-plugin-sdk/tflint" 10 | ) 11 | 12 | // TestRule is a container for tests defined by Rego to satisfy tflint.Rule 13 | type TestRule struct { 14 | tflint.DefaultRule 15 | 16 | engine *Engine 17 | 18 | name string 19 | regoName string 20 | location *location.Location 21 | } 22 | 23 | var _ tflint.Rule = (*TestRule)(nil) 24 | 25 | // NewTestRule returns a tflint.Rule from a Rego rule. 26 | // Note that the rule names in TFLint and in Rego are different. 27 | func NewTestRule(regoRule *ast.Rule, engine *Engine) *TestRule { 28 | regoName := regoRule.Head.Name.String() 29 | 30 | // All valid tests must start with "test_" (e.g. test_deny) 31 | if !strings.HasPrefix(regoName, "test_") { 32 | return nil 33 | } 34 | 35 | return &TestRule{ 36 | engine: engine, 37 | // Add "opa_" to the rule name in TFLint (e.g. opa_test_deny) 38 | name: fmt.Sprintf("opa_%s", regoName), 39 | regoName: regoName, 40 | location: regoRule.Location, 41 | } 42 | } 43 | 44 | func (r *TestRule) Name() string { 45 | return r.name 46 | } 47 | 48 | func (r *TestRule) Enabled() bool { 49 | return true 50 | } 51 | 52 | func (r *TestRule) Severity() tflint.Severity { 53 | // Severity is always error 54 | return tflint.ERROR 55 | } 56 | 57 | func (r *TestRule) Link() string { 58 | return r.location.String() 59 | } 60 | 61 | func (r *TestRule) Check(runner tflint.Runner) error { 62 | issues, err := r.engine.RunTest(r, runner) 63 | if err != nil { 64 | return err 65 | } 66 | 67 | for _, issue := range issues { 68 | if err := runner.EmitIssue(r, issue.Message, issue.Range); err != nil { 69 | return err 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func (r *TestRule) RegoName() string { 77 | return r.regoName 78 | } 79 | -------------------------------------------------------------------------------- /opa/test_rule_test.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/liamg/memoryfs" 7 | "github.com/open-policy-agent/opa/v1/ast" 8 | "github.com/open-policy-agent/opa/v1/loader" 9 | "github.com/terraform-linters/tflint-plugin-sdk/helper" 10 | ) 11 | 12 | func TestNewTestRule(t *testing.T) { 13 | tests := []struct { 14 | name string 15 | rule *ast.Rule 16 | want *TestRule 17 | }{ 18 | { 19 | name: "test rule", 20 | rule: &ast.Rule{Head: &ast.Head{Name: "test_deny"}}, 21 | want: &TestRule{name: "opa_test_deny"}, 22 | }, 23 | { 24 | name: "non-test rule", 25 | rule: &ast.Rule{Head: &ast.Head{Name: "deny_test"}}, 26 | want: nil, 27 | }, 28 | } 29 | 30 | for _, test := range tests { 31 | t.Run(test.name, func(t *testing.T) { 32 | rule := NewTestRule(test.rule, nil) 33 | if rule == nil { 34 | if test.want == nil { 35 | return 36 | } 37 | t.Fatal("rule is nil") 38 | } 39 | 40 | if test.want.name != rule.name { 41 | t.Fatalf("want: %s, got: %s", test.want.name, rule.name) 42 | } 43 | }) 44 | } 45 | } 46 | 47 | func TestCheck_test_not_deny_t2_micro(t *testing.T) { 48 | fs := memoryfs.New() 49 | test := ` 50 | package tflint 51 | 52 | import rego.v1 53 | 54 | mock_resources_t1_micro(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` + "`" + ` 55 | resource "aws_instance" "main" { 56 | instance_type = "t1.micro" 57 | }` + "`" + `}) 58 | 59 | test_not_deny_t2_micro if { 60 | issues := deny_not_t2_micro with terraform.resources as mock_resources_t1_micro 61 | 62 | count(issues) == 1 63 | issue := issues[_] 64 | issue.msg == "t2.micro is only allowed" 65 | }` 66 | fs.WriteFile("main_test.rego", []byte(test), 0o644) 67 | 68 | tests := []struct { 69 | name string 70 | policy string 71 | want helper.Issues 72 | }{ 73 | { 74 | name: "test failed", 75 | policy: ` 76 | package tflint 77 | 78 | import rego.v1 79 | 80 | deny_not_t2_micro contains issue if { 81 | resources := terraform.resources("aws_db_instance", {"instance_type": "string"}, {}) 82 | instance_type := resources[_].config.instance_type 83 | 84 | instance_type.value != "t2.micro" 85 | 86 | issue := tflint.issue("t2.micro is only allowed", instance_type.range) 87 | }`, 88 | want: helper.Issues{ 89 | { 90 | Rule: &TestRule{}, 91 | Message: "test failed", 92 | }, 93 | }, 94 | }, 95 | { 96 | name: "test passed", 97 | policy: ` 98 | package tflint 99 | 100 | import rego.v1 101 | 102 | deny_not_t2_micro contains issue if { 103 | resources := terraform.resources("aws_instance", {"instance_type": "string"}, {}) 104 | instance_type := resources[_].config.instance_type 105 | 106 | instance_type.value != "t2.micro" 107 | 108 | issue := tflint.issue("t2.micro is only allowed", instance_type.range) 109 | }`, 110 | want: helper.Issues{}, 111 | }, 112 | } 113 | 114 | for _, test := range tests { 115 | t.Run(test.name, func(t *testing.T) { 116 | fs.WriteFile("main.rego", []byte(test.policy), 0o644) 117 | 118 | ret, err := loader.NewFileLoader().WithFS(fs).Filtered([]string{"."}, nil) 119 | if err != nil { 120 | t.Fatal(err) 121 | } 122 | engine, err := NewEngine(ret) 123 | if err != nil { 124 | t.Fatal(err) 125 | } 126 | rule := NewTestRule(&ast.Rule{Head: &ast.Head{Name: "test_not_deny_t2_micro"}}, engine) 127 | 128 | runner := helper.TestRunner(t, map[string]string{}) 129 | if err := rule.Check(runner); err != nil { 130 | t.Fatal(err) 131 | } 132 | 133 | helper.AssertIssues(t, test.want, runner.Issues) 134 | }) 135 | } 136 | } 137 | 138 | func TestCheck_test_deny_not_snake_case(t *testing.T) { 139 | fs := memoryfs.New() 140 | test := ` 141 | package tflint 142 | 143 | import rego.v1 144 | 145 | mock_resources(type, schema, options) := terraform.mock_resources(type, schema, options, {"main.tf": ` + "`" + ` 146 | resource "aws_instance" "main-v2" {} 147 | ` + "`" + `}) 148 | 149 | test_deny_not_snake_case if { 150 | issues := deny_not_snake_case 151 | with terraform.resources as mock_resources 152 | 153 | count(issues) == 1 154 | issue := issues[_] 155 | issue.msg == "main-v2 is not snake case" 156 | issue.range.start.line == 2 157 | }` 158 | fs.WriteFile("main_test.rego", []byte(test), 0o644) 159 | 160 | tests := []struct { 161 | name string 162 | policy string 163 | want helper.Issues 164 | }{ 165 | { 166 | name: "test failed", 167 | policy: ` 168 | package tflint 169 | 170 | import rego.v1 171 | 172 | deny_not_snake_case contains issue if { 173 | resources := terraform.resources("*", {}, {}) 174 | regex.match("^[a-z][a-z0-9]*(_[a-z0-9]+)*$", resources[i].name) 175 | 176 | issue := tflint.issue(sprintf("%s is not snake case", [resources[i].name]), resources[i].decl_range) 177 | }`, 178 | want: helper.Issues{ 179 | { 180 | Rule: &TestRule{}, 181 | Message: "test failed", 182 | }, 183 | }, 184 | }, 185 | { 186 | name: "test passed", 187 | policy: ` 188 | package tflint 189 | 190 | import rego.v1 191 | 192 | deny_not_snake_case contains issue if { 193 | resources := terraform.resources("*", {}, {}) 194 | not regex.match("^[a-z][a-z0-9]*(_[a-z0-9]+)*$", resources[i].name) 195 | 196 | issue := tflint.issue(sprintf("%s is not snake case", [resources[i].name]), resources[i].decl_range) 197 | }`, 198 | want: helper.Issues{}, 199 | }, 200 | } 201 | 202 | for _, test := range tests { 203 | t.Run(test.name, func(t *testing.T) { 204 | fs.WriteFile("main.rego", []byte(test.policy), 0o644) 205 | 206 | ret, err := loader.NewFileLoader().WithFS(fs).Filtered([]string{"."}, nil) 207 | if err != nil { 208 | t.Fatal(err) 209 | } 210 | engine, err := NewEngine(ret) 211 | if err != nil { 212 | t.Fatal(err) 213 | } 214 | rule := NewTestRule(&ast.Rule{Head: &ast.Head{Name: "test_deny_not_snake_case"}}, engine) 215 | 216 | runner := helper.TestRunner(t, map[string]string{}) 217 | if err := rule.Check(runner); err != nil { 218 | t.Fatal(err) 219 | } 220 | 221 | helper.AssertIssues(t, test.want, runner.Issues) 222 | }) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /opa/test_runner.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/hashicorp/hcl/v2" 7 | "github.com/hashicorp/hcl/v2/gohcl" 8 | "github.com/hashicorp/hcl/v2/hclparse" 9 | "github.com/terraform-linters/tflint-plugin-sdk/hclext" 10 | "github.com/terraform-linters/tflint-plugin-sdk/terraform/addrs" 11 | "github.com/terraform-linters/tflint-plugin-sdk/terraform/lang/marks" 12 | "github.com/terraform-linters/tflint-plugin-sdk/tflint" 13 | "github.com/zclconf/go-cty/cty" 14 | "github.com/zclconf/go-cty/cty/gocty" 15 | ) 16 | 17 | // testRunner is a pseudo runner used in policy testing. 18 | // This can be used to inspect Terraform config files written within tests. 19 | // Since it is different from a real gRPC client, some features are limited. 20 | type testRunner struct { 21 | files map[string]*hcl.File 22 | variables map[string]*variable 23 | } 24 | 25 | type variable struct { 26 | Name string 27 | Default cty.Value 28 | Sensitive bool 29 | Ephemeral bool 30 | DeclRange hcl.Range 31 | } 32 | 33 | var _ tflint.Runner = (*testRunner)(nil) 34 | 35 | func NewTestRunner(files map[string]string) (*testRunner, hcl.Diagnostics) { 36 | runner := &testRunner{ 37 | files: map[string]*hcl.File{}, 38 | variables: map[string]*variable{}, 39 | } 40 | parser := hclparse.NewParser() 41 | 42 | for name, src := range files { 43 | var file *hcl.File 44 | var diags hcl.Diagnostics 45 | if strings.HasSuffix(name, ".json") { 46 | file, diags = parser.ParseJSON([]byte(src), name) 47 | } else { 48 | file, diags = parser.ParseHCL([]byte(src), name) 49 | } 50 | if diags.HasErrors() { 51 | return runner, diags 52 | } 53 | 54 | runner.files[name] = file 55 | } 56 | 57 | for _, file := range runner.files { 58 | content, _, diags := file.Body.PartialContent(configFileSchema) 59 | if diags.HasErrors() { 60 | return runner, diags 61 | } 62 | 63 | for _, block := range content.Blocks { 64 | switch block.Type { 65 | case "variable": 66 | // Only "variable" blocks are interpreted 67 | variable, diags := decodeVariableBlock(block) 68 | if diags.HasErrors() { 69 | return runner, diags 70 | } 71 | runner.variables[variable.Name] = variable 72 | default: 73 | continue 74 | } 75 | } 76 | } 77 | 78 | return runner, nil 79 | } 80 | 81 | // GetModuleContent gets a content of the module. 82 | // dynamic blocks, meta-arguments and overrides are not considered 83 | func (r *testRunner) GetModuleContent(schema *hclext.BodySchema, _ *tflint.GetModuleContentOption) (*hclext.BodyContent, error) { 84 | content := &hclext.BodyContent{} 85 | diags := hcl.Diagnostics{} 86 | 87 | for _, f := range r.files { 88 | c, d := hclext.PartialContent(f.Body, schema) 89 | diags = diags.Extend(d) 90 | for name, attr := range c.Attributes { 91 | content.Attributes[name] = attr 92 | } 93 | content.Blocks = append(content.Blocks, c.Blocks...) 94 | } 95 | 96 | if diags.HasErrors() { 97 | return nil, diags 98 | } 99 | return content, nil 100 | } 101 | 102 | var sensitiveMark = cty.NewValueMarks(marks.Sensitive) 103 | var ephemeralMark = cty.NewValueMarks(marks.Ephemeral) 104 | 105 | // EvaluateExpr returns a value of the passed expression. 106 | // Not expected to reflect anything other than cty.Value. 107 | // It is an error to evaluate anything other than a variable. 108 | // Functions are also not supported. 109 | func (r *testRunner) EvaluateExpr(expr hcl.Expression, ret interface{}, _ *tflint.EvaluateExprOption) error { 110 | variables := map[string]cty.Value{} 111 | for _, variable := range r.variables { 112 | val := variable.Default 113 | if val == cty.NilVal { 114 | val = cty.DynamicVal 115 | } 116 | if variable.Sensitive { 117 | val = val.WithMarks(sensitiveMark) 118 | } 119 | if variable.Ephemeral { 120 | val = val.WithMarks(ephemeralMark) 121 | } 122 | variables[variable.Name] = val 123 | } 124 | 125 | val, diags := expr.Value(&hcl.EvalContext{ 126 | Variables: map[string]cty.Value{ 127 | "var": cty.ObjectVal(variables), 128 | }, 129 | }) 130 | if diags.HasErrors() { 131 | return diags 132 | } 133 | 134 | return gocty.FromCtyValue(val, ret) 135 | } 136 | 137 | // GetFile returns the hcl.File object 138 | func (r *testRunner) GetFile(filename string) (*hcl.File, error) { 139 | return r.files[filename], nil 140 | } 141 | 142 | // GetFiles returns all hcl.File 143 | func (r *testRunner) GetFiles() (map[string]*hcl.File, error) { 144 | return r.files, nil 145 | } 146 | 147 | func (r *testRunner) DecodeRuleConfig(name string, ret interface{}) error { 148 | panic("Not implemented in test runner") 149 | } 150 | 151 | func (r *testRunner) EmitIssue(rule tflint.Rule, message string, location hcl.Range) error { 152 | panic("Not implemented in test runner") 153 | } 154 | 155 | func (r *testRunner) EmitIssueWithFix(rule tflint.Rule, message string, location hcl.Range, fixFunc func(f tflint.Fixer) error) error { 156 | panic("Not implemented in test runner") 157 | } 158 | 159 | func (r *testRunner) EnsureNoError(err error, proc func() error) error { 160 | panic("Not implemented in test runner") 161 | } 162 | 163 | func (r *testRunner) GetModulePath() (addrs.Module, error) { 164 | panic("Not implemented in test runner") 165 | } 166 | 167 | func (r *testRunner) GetOriginalwd() (string, error) { 168 | panic("Not implemented in test runner") 169 | } 170 | 171 | func (r *testRunner) GetProviderContent(name string, schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, error) { 172 | panic("Not implemented in test runner") 173 | } 174 | 175 | func (r *testRunner) GetResourceContent(name string, schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, error) { 176 | panic("Not implemented in test runner") 177 | } 178 | 179 | func (r *testRunner) WalkExpressions(walker tflint.ExprWalker) hcl.Diagnostics { 180 | panic("Not implemented in test runner") 181 | } 182 | 183 | func decodeVariableBlock(block *hcl.Block) (*variable, hcl.Diagnostics) { 184 | v := &variable{ 185 | Name: block.Labels[0], 186 | DeclRange: block.DefRange, 187 | } 188 | 189 | content, _, diags := block.Body.PartialContent(&hcl.BodySchema{ 190 | // Only supports "default", "sensitive", and "ephemeral" 191 | Attributes: []hcl.AttributeSchema{ 192 | { 193 | Name: "default", 194 | }, 195 | { 196 | Name: "sensitive", 197 | }, 198 | { 199 | Name: "ephemeral", 200 | }, 201 | }, 202 | }) 203 | if diags.HasErrors() { 204 | return v, diags 205 | } 206 | 207 | if attr, exists := content.Attributes["default"]; exists { 208 | val, diags := attr.Expr.Value(nil) 209 | if diags.HasErrors() { 210 | return v, diags 211 | } 212 | 213 | v.Default = val 214 | } 215 | 216 | if attr, exists := content.Attributes["sensitive"]; exists { 217 | diags := gohcl.DecodeExpression(attr.Expr, nil, &v.Sensitive) 218 | if diags.HasErrors() { 219 | return v, diags 220 | } 221 | } 222 | if attr, exists := content.Attributes["ephemeral"]; exists { 223 | diags := gohcl.DecodeExpression(attr.Expr, nil, &v.Ephemeral) 224 | if diags.HasErrors() { 225 | return v, diags 226 | } 227 | } 228 | 229 | return v, nil 230 | } 231 | 232 | var configFileSchema = &hcl.BodySchema{ 233 | Blocks: []hcl.BlockHeaderSchema{ 234 | { 235 | Type: "variable", 236 | LabelNames: []string{"name"}, 237 | }, 238 | }, 239 | } 240 | -------------------------------------------------------------------------------- /opa/test_runner_test.go: -------------------------------------------------------------------------------- 1 | package opa 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/google/go-cmp/cmp" 8 | "github.com/google/go-cmp/cmp/cmpopts" 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/hashicorp/hcl/v2/hclsyntax" 11 | "github.com/terraform-linters/tflint-plugin-sdk/hclext" 12 | "github.com/zclconf/go-cty/cty" 13 | ) 14 | 15 | func TestGetModuleContent(t *testing.T) { 16 | tests := []struct { 17 | name string 18 | config string 19 | schema *hclext.BodySchema 20 | want *hclext.BodyContent 21 | }{ 22 | { 23 | name: "attribute", 24 | config: ` 25 | resource "aws_instance" "foo" { 26 | ami = "ami-123456" 27 | instance_type = "t2.micro" 28 | }`, 29 | schema: &hclext.BodySchema{ 30 | Blocks: []hclext.BlockSchema{ 31 | { 32 | Type: "resource", 33 | LabelNames: []string{"type", "name"}, 34 | Body: &hclext.BodySchema{ 35 | Attributes: []hclext.AttributeSchema{{Name: "instance_type"}}, 36 | }, 37 | }, 38 | }, 39 | }, 40 | want: &hclext.BodyContent{ 41 | Blocks: hclext.Blocks{ 42 | { 43 | Type: "resource", 44 | Labels: []string{"aws_instance", "foo"}, 45 | Body: &hclext.BodyContent{ 46 | Attributes: hclext.Attributes{ 47 | "instance_type": { 48 | Name: "instance_type", 49 | Expr: &hclsyntax.TemplateExpr{ 50 | Parts: []hclsyntax.Expression{ 51 | &hclsyntax.LiteralValueExpr{ 52 | SrcRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 4, Column: 19}, End: hcl.Pos{Line: 4, Column: 27}}, 53 | }, 54 | }, 55 | SrcRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 4, Column: 18}, End: hcl.Pos{Line: 4, Column: 28}}, 56 | }, 57 | Range: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 4, Column: 2}, End: hcl.Pos{Line: 4, Column: 28}}, 58 | NameRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 4, Column: 2}, End: hcl.Pos{Line: 4, Column: 15}}, 59 | }, 60 | }, 61 | Blocks: hclext.Blocks{}, 62 | }, 63 | DefRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 2, Column: 1}, End: hcl.Pos{Line: 2, Column: 30}}, 64 | TypeRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 2, Column: 1}, End: hcl.Pos{Line: 2, Column: 9}}, 65 | LabelRanges: []hcl.Range{ 66 | {Filename: "main.tf", Start: hcl.Pos{Line: 2, Column: 10}, End: hcl.Pos{Line: 2, Column: 24}}, 67 | {Filename: "main.tf", Start: hcl.Pos{Line: 2, Column: 25}, End: hcl.Pos{Line: 2, Column: 30}}, 68 | }, 69 | }, 70 | }, 71 | }, 72 | }, 73 | { 74 | name: "block", 75 | config: ` 76 | resource "aws_instance" "foo" { 77 | ami = "ami-123456" 78 | ebs_block_device { 79 | volume_size = 16 80 | } 81 | }`, 82 | schema: &hclext.BodySchema{ 83 | Blocks: []hclext.BlockSchema{ 84 | { 85 | Type: "resource", 86 | LabelNames: []string{"type", "name"}, 87 | Body: &hclext.BodySchema{ 88 | Blocks: []hclext.BlockSchema{ 89 | {Type: "ebs_block_device", Body: &hclext.BodySchema{Attributes: []hclext.AttributeSchema{{Name: "volume_size"}}}}, 90 | }, 91 | }, 92 | }, 93 | }, 94 | }, 95 | want: &hclext.BodyContent{ 96 | Blocks: hclext.Blocks{ 97 | { 98 | Type: "resource", 99 | Labels: []string{"aws_instance", "foo"}, 100 | Body: &hclext.BodyContent{ 101 | Attributes: hclext.Attributes{}, 102 | Blocks: hclext.Blocks{ 103 | { 104 | Type: "ebs_block_device", 105 | Body: &hclext.BodyContent{ 106 | Attributes: hclext.Attributes{ 107 | "volume_size": { 108 | Name: "volume_size", 109 | Expr: &hclsyntax.LiteralValueExpr{ 110 | SrcRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 5, Column: 17}, End: hcl.Pos{Line: 5, Column: 19}}, 111 | }, 112 | Range: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 5, Column: 3}, End: hcl.Pos{Line: 5, Column: 19}}, 113 | NameRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 5, Column: 3}, End: hcl.Pos{Line: 5, Column: 14}}, 114 | }, 115 | }, 116 | Blocks: hclext.Blocks{}, 117 | }, 118 | DefRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 4, Column: 2}, End: hcl.Pos{Line: 4, Column: 18}}, 119 | TypeRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 4, Column: 2}, End: hcl.Pos{Line: 4, Column: 18}}, 120 | }, 121 | }, 122 | }, 123 | DefRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 2, Column: 1}, End: hcl.Pos{Line: 2, Column: 30}}, 124 | TypeRange: hcl.Range{Filename: "main.tf", Start: hcl.Pos{Line: 2, Column: 1}, End: hcl.Pos{Line: 2, Column: 9}}, 125 | LabelRanges: []hcl.Range{ 126 | {Filename: "main.tf", Start: hcl.Pos{Line: 2, Column: 10}, End: hcl.Pos{Line: 2, Column: 24}}, 127 | {Filename: "main.tf", Start: hcl.Pos{Line: 2, Column: 25}, End: hcl.Pos{Line: 2, Column: 30}}, 128 | }, 129 | }, 130 | }, 131 | }, 132 | }, 133 | } 134 | 135 | for _, test := range tests { 136 | t.Run(test.name, func(t *testing.T) { 137 | runner, diags := NewTestRunner(map[string]string{"main.tf": test.config}) 138 | if diags.HasErrors() { 139 | t.Fatal(diags) 140 | } 141 | 142 | got, err := runner.GetModuleContent(test.schema, nil) 143 | if err != nil { 144 | t.Fatal(err) 145 | } 146 | 147 | opts := cmp.Options{ 148 | cmpopts.IgnoreFields(hclsyntax.LiteralValueExpr{}, "Val"), 149 | cmpopts.IgnoreFields(hcl.Pos{}, "Byte"), 150 | } 151 | if diff := cmp.Diff(test.want, got, opts...); diff != "" { 152 | t.Error(diff) 153 | } 154 | }) 155 | } 156 | } 157 | 158 | func TestGetModuleContent_json(t *testing.T) { 159 | files := map[string]string{ 160 | "main.tf.json": `{"variable": {"foo": {"type": "string"}}}`, 161 | } 162 | 163 | runner, diags := NewTestRunner(files) 164 | if diags.HasErrors() { 165 | t.Fatal(diags) 166 | } 167 | 168 | schema := &hclext.BodySchema{ 169 | Blocks: []hclext.BlockSchema{ 170 | { 171 | Type: "variable", 172 | Body: &hclext.BodySchema{ 173 | Blocks: []hclext.BlockSchema{ 174 | { 175 | Type: "type", 176 | LabelNames: []string{"name"}, 177 | Body: &hclext.BodySchema{}, 178 | }, 179 | }, 180 | }, 181 | }, 182 | }, 183 | } 184 | got, err := runner.GetModuleContent(schema, nil) 185 | if err != nil { 186 | t.Fatal(err) 187 | } 188 | 189 | if len(got.Blocks) != 1 { 190 | t.Errorf("got %d blocks, but 1 block is expected", len(got.Blocks)) 191 | } 192 | } 193 | 194 | func TestEvaluateExpr(t *testing.T) { 195 | parse := func(src string) hcl.Expression { 196 | expr, diags := hclsyntax.ParseExpression([]byte(src), "main.tf", hcl.InitialPos) 197 | if diags.HasErrors() { 198 | t.Fatal(diags) 199 | } 200 | return expr 201 | } 202 | 203 | tests := []struct { 204 | name string 205 | config string 206 | expr hcl.Expression 207 | want string 208 | err error 209 | }{ 210 | { 211 | name: "literal", 212 | expr: parse(`"t2.micro"`), 213 | want: `cty.StringVal("t2.micro")`, 214 | }, 215 | { 216 | name: "variable", 217 | config: ` 218 | variable "instance_type" { 219 | default = "t2.micro" 220 | }`, 221 | expr: parse("var.instance_type"), 222 | want: `cty.StringVal("t2.micro")`, 223 | }, 224 | { 225 | name: "variable without default", 226 | config: `variable "instance_type" {}`, 227 | expr: parse("var.instance_type"), 228 | want: `cty.DynamicVal`, 229 | }, 230 | { 231 | name: "sensitive variable", 232 | config: ` 233 | variable "instance_type" { 234 | default = "t2.micro" 235 | sensitive = true 236 | }`, 237 | expr: parse("var.instance_type"), 238 | want: `cty.StringVal("t2.micro").Mark(marks.Sensitive)`, 239 | }, 240 | { 241 | name: "ephemeral variable", 242 | config: ` 243 | variable "instance_type" { 244 | default = "t2.micro" 245 | ephemeral = true 246 | }`, 247 | expr: parse("var.instance_type"), 248 | want: `cty.StringVal("t2.micro").Mark(marks.Ephemeral)`, 249 | }, 250 | } 251 | 252 | for _, test := range tests { 253 | t.Run(test.name, func(t *testing.T) { 254 | runner, diags := NewTestRunner(map[string]string{"main.tf": test.config}) 255 | if diags.HasErrors() { 256 | t.Fatal(diags) 257 | } 258 | 259 | var got cty.Value 260 | err := runner.EvaluateExpr(test.expr, &got, nil) 261 | if err != nil { 262 | if !errors.Is(err, test.err) { 263 | t.Fatal(err) 264 | } 265 | return 266 | } 267 | if err == nil && test.err != nil { 268 | t.Fatal("should return an error, but it does not") 269 | } 270 | 271 | if test.want != got.GoString() { 272 | t.Fatalf("want: %s, got: %s", test.want, got.GoString()) 273 | } 274 | }) 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /tools/release/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/terraform-linters/tflint-ruleset-opa/tools/release 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/google/go-github/v69 v69.2.0 7 | github.com/hashicorp/go-version v1.7.0 8 | golang.org/x/oauth2 v0.28.0 9 | ) 10 | 11 | require github.com/google/go-querystring v1.1.0 // indirect 12 | -------------------------------------------------------------------------------- /tools/release/go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 2 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 3 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 4 | github.com/google/go-github/v69 v69.2.0 h1:wR+Wi/fN2zdUx9YxSmYE0ktiX9IAR/BeePzeaUUbEHE= 5 | github.com/google/go-github/v69 v69.2.0/go.mod h1:xne4jymxLR6Uj9b7J7PyTpkMYstEMMwGZa0Aehh1azM= 6 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 7 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 8 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 9 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 10 | golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc= 11 | golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 12 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 13 | -------------------------------------------------------------------------------- /tools/release/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "fmt" 8 | "io" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "regexp" 13 | "strings" 14 | 15 | "github.com/google/go-github/v69/github" 16 | "github.com/hashicorp/go-version" 17 | "golang.org/x/oauth2" 18 | ) 19 | 20 | var token = os.Getenv("GITHUB_TOKEN") 21 | var versionRegexp = regexp.MustCompile(`^\d+\.\d+\.\d+$`) 22 | var goModRequireSDKRegexp = regexp.MustCompile(`github\.com/terraform-linters/tflint-plugin-sdk v(.+)`) 23 | 24 | func main() { 25 | if err := os.Chdir("../../"); err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | currentVersion := getCurrentVersion() 30 | log.Printf("current version: %s", currentVersion) 31 | 32 | newVersion := getNewVersion() 33 | log.Printf("new version: %s", newVersion) 34 | 35 | releaseNotePath := "tools/release/release-note.md" 36 | 37 | log.Println("checking requirements...") 38 | if err := checkRequirements(currentVersion, newVersion); err != nil { 39 | log.Fatal(err) 40 | } 41 | 42 | log.Println("rewriting files with new version...") 43 | if err := rewriteFileWithNewVersion("main.go", currentVersion, newVersion); err != nil { 44 | log.Fatal(err) 45 | } 46 | if err := rewriteFileWithNewVersion("README.md", currentVersion, newVersion); err != nil { 47 | log.Fatal(err) 48 | } 49 | 50 | log.Println("generating release notes...") 51 | if err := generateReleaseNote(currentVersion, newVersion, releaseNotePath); err != nil { 52 | log.Fatal(err) 53 | } 54 | if err := editFileInteractive(releaseNotePath); err != nil { 55 | log.Fatal(err) 56 | } 57 | 58 | log.Println("installing and running tests...") 59 | if err := execCommand(os.Stdout, "make", "test"); err != nil { 60 | log.Fatal(err) 61 | } 62 | if err := execCommand(os.Stdout, "make", "install"); err != nil { 63 | log.Fatal(err) 64 | } 65 | if err := execCommand(os.Stdout, "make", "e2e"); err != nil { 66 | log.Fatal(err) 67 | } 68 | 69 | log.Println("committing and tagging...") 70 | if err := execCommand(os.Stdout, "git", "add", "."); err != nil { 71 | log.Fatal(err) 72 | } 73 | if err := execCommand(os.Stdout, "git", "commit", "-m", fmt.Sprintf("Bump up version to v%s", newVersion)); err != nil { 74 | log.Fatal(err) 75 | } 76 | if err := execCommand(os.Stdout, "git", "tag", fmt.Sprintf("v%s", newVersion)); err != nil { 77 | log.Fatal(err) 78 | } 79 | if err := execCommand(os.Stdout, "git", "push", "origin", "main", "--tags"); err != nil { 80 | log.Fatal(err) 81 | } 82 | log.Printf("pushed v%s", newVersion) 83 | } 84 | 85 | func getCurrentVersion() string { 86 | stdout := &bytes.Buffer{} 87 | if err := execCommand(stdout, "git", "describe", "--tags", "--abbrev=0"); err != nil { 88 | log.Fatal(err) 89 | } 90 | return strings.TrimPrefix(strings.TrimSpace(stdout.String()), "v") 91 | } 92 | 93 | func getNewVersion() string { 94 | reader := bufio.NewReader(os.Stdin) 95 | fmt.Print(`Enter new version (without leading "v"): `) 96 | input, err := reader.ReadString('\n') 97 | if err != nil { 98 | log.Fatal(fmt.Errorf("failed to read user input: %w", err)) 99 | } 100 | version := strings.TrimSpace(input) 101 | 102 | if !versionRegexp.MatchString(version) { 103 | log.Fatal(fmt.Errorf("invalid version: %s", version)) 104 | } 105 | return version 106 | } 107 | 108 | func checkRequirements(old string, new string) error { 109 | if token == "" { 110 | return fmt.Errorf("GITHUB_TOKEN is not set. Required to generate release notes") 111 | } 112 | 113 | if _, err := exec.LookPath("tflint"); err != nil { 114 | return fmt.Errorf("TFLint is not installed. Required to run E2E tests") 115 | } 116 | 117 | oldVersion, err := version.NewVersion(old) 118 | if err != nil { 119 | return fmt.Errorf("failed to parse current version: %w", err) 120 | } 121 | newVersion, err := version.NewVersion(new) 122 | if err != nil { 123 | return fmt.Errorf("failed to parse new version: %w", err) 124 | } 125 | if !newVersion.GreaterThan(oldVersion) { 126 | return fmt.Errorf("new version must be greater than current version") 127 | } 128 | 129 | if err := checkGitStatus(); err != nil { 130 | return fmt.Errorf("failed to check Git status: %w", err) 131 | } 132 | 133 | if err := checkGoModules(); err != nil { 134 | return fmt.Errorf("failed to check Go modules: %w", err) 135 | } 136 | return nil 137 | } 138 | 139 | func checkGitStatus() error { 140 | stdout := &bytes.Buffer{} 141 | if err := execCommand(stdout, "git", "status", "--porcelain"); err != nil { 142 | return err 143 | } 144 | if strings.TrimSpace(stdout.String()) != "" { 145 | return fmt.Errorf("the current working tree is dirty. Please commit or stash changes") 146 | } 147 | 148 | stdout = &bytes.Buffer{} 149 | if err := execCommand(stdout, "git", "rev-parse", "--abbrev-ref", "HEAD"); err != nil { 150 | return err 151 | } 152 | if strings.TrimSpace(stdout.String()) != "main" { 153 | return fmt.Errorf("the current branch is not main, got %s", strings.TrimSpace(stdout.String())) 154 | } 155 | 156 | stdout = &bytes.Buffer{} 157 | if err := execCommand(stdout, "git", "config", "--get", "remote.origin.url"); err != nil { 158 | return err 159 | } 160 | if !strings.Contains(strings.TrimSpace(stdout.String()), "terraform-linters/tflint-ruleset-opa") { 161 | return fmt.Errorf("remote.origin is not terraform-linters/tflint-ruleset-opa, got %s", strings.TrimSpace(stdout.String())) 162 | } 163 | return nil 164 | } 165 | 166 | func checkGoModules() error { 167 | bytes, err := os.ReadFile("go.mod") 168 | if err != nil { 169 | return fmt.Errorf("failed to read go.mod: %w", err) 170 | } 171 | content := string(bytes) 172 | 173 | matches := goModRequireSDKRegexp.FindStringSubmatch(content) 174 | if len(matches) != 2 { 175 | return fmt.Errorf(`failed to parse go.mod: did not match "%s"`, goModRequireSDKRegexp.String()) 176 | } 177 | if !versionRegexp.MatchString(matches[1]) { 178 | return fmt.Errorf(`failed to parse go.mod: SDK version "%s" is not stable`, matches[1]) 179 | } 180 | return nil 181 | } 182 | 183 | func rewriteFileWithNewVersion(path string, old string, new string) error { 184 | log.Printf("rewrite %s", path) 185 | 186 | bytes, err := os.ReadFile(path) 187 | if err != nil { 188 | return fmt.Errorf("failed to read %s: %w", path, err) 189 | } 190 | content := string(bytes) 191 | 192 | replaced := strings.ReplaceAll(content, old, new) 193 | if replaced == content { 194 | return fmt.Errorf("%s is not changed", path) 195 | } 196 | 197 | if err := os.WriteFile(path, []byte(replaced), 0644); err != nil { 198 | return fmt.Errorf("failed to write %s: %w", path, err) 199 | } 200 | return nil 201 | } 202 | 203 | func generateReleaseNote(old string, new string, savedPath string) error { 204 | tagName := fmt.Sprintf("v%s", new) 205 | previousTagName := fmt.Sprintf("v%s", old) 206 | targetCommitish := "main" 207 | 208 | client := github.NewClient(oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{ 209 | AccessToken: token, 210 | }))) 211 | 212 | note, _, err := client.Repositories.GenerateReleaseNotes( 213 | context.Background(), 214 | "terraform-linters", 215 | "tflint-ruleset-opa", 216 | &github.GenerateNotesOptions{ 217 | TagName: tagName, 218 | PreviousTagName: &previousTagName, 219 | TargetCommitish: &targetCommitish, 220 | }, 221 | ) 222 | if err != nil { 223 | return fmt.Errorf("failed to generate release notes: %w", err) 224 | } 225 | 226 | if err := os.WriteFile(savedPath, []byte(note.Body), 0644); err != nil { 227 | return fmt.Errorf("failed to write %s: %w", savedPath, err) 228 | } 229 | return err 230 | } 231 | 232 | func editFileInteractive(path string) error { 233 | editor := "vi" 234 | if e := os.Getenv("EDITOR"); e != "" { 235 | editor = e 236 | } 237 | return execShellCommand(os.Stdout, fmt.Sprintf("%s %s", editor, path)) 238 | } 239 | 240 | func execShellCommand(stdout io.Writer, command string) error { 241 | shell := "sh" 242 | if s := os.Getenv("SHELL"); s != "" { 243 | shell = s 244 | } 245 | 246 | return execCommand(stdout, shell, "-c", command) 247 | } 248 | 249 | func execCommand(stdout io.Writer, name string, args ...string) error { 250 | cmd := exec.Command(name, args...) 251 | cmd.Stdin = os.Stdin 252 | cmd.Stdout = stdout 253 | cmd.Stderr = os.Stderr 254 | 255 | if err := cmd.Run(); err != nil { 256 | commands := append([]string{name}, args...) 257 | return fmt.Errorf(`failed to exec "%s": %w`, strings.Join(commands, " "), err) 258 | } 259 | return nil 260 | } 261 | -------------------------------------------------------------------------------- /tools/release/release-note.md: -------------------------------------------------------------------------------- 1 | ## What's Changed 2 | 3 | In the OPA ruleset v0.8, we upgraded the embedded OPA version from v0.70 to v1.2. This means that some deprecated features will no longer be available and policies will need to be rewritten. See also https://www.openpolicyagent.org/docs/v1.2.0/v0-upgrade 4 | 5 | If you use v0 syntax (without `if` and `contains` keywords in rule head declarations), it is recommended to use `opa fmt --write --v0-v1` to automatically rewrite your policy files. See also https://www.openpolicyagent.org/docs/v1.2.0/v0-upgrade/#upgrading-rego 6 | 7 | Another new feature worth mentioning is support for [ephemeral resources](https://developer.hashicorp.com/terraform/language/resources/ephemeral), which was added in Terraform v1.10. You can get "ephemeral" blocks by using the `terraform.ephemeral_resources` function. Also, because `ephemeral` attribute has been added in an expression, you can write policies such as "passwords must be ephemeral". 8 | 9 | ### Breaking Changes 10 | * Promote OPA 1.0 by @wata727 in https://github.com/terraform-linters/tflint-ruleset-opa/pull/136 11 | 12 | ### Enhancements 13 | * Bump github.com/terraform-linters/tflint-plugin-sdk from 0.20.0 to 0.22.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/125 14 | * Add support for ephemeral mark by @wata727 in https://github.com/terraform-linters/tflint-ruleset-opa/pull/133 15 | * Add `terraform.ephemeral_resources` function by @wata727 in https://github.com/terraform-linters/tflint-ruleset-opa/pull/135 16 | 17 | ### Chores 18 | * release: Introduce Artifact Attestations by @wata727 in https://github.com/terraform-linters/tflint-ruleset-opa/pull/106 19 | * Bump goreleaser/goreleaser-action from 5 to 6 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/108 20 | * Bump github.com/hashicorp/hcl/v2 from 2.20.1 to 2.21.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/109 21 | * Bump github.com/open-policy-agent/opa from 0.64.1 to 0.65.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/107 22 | * Bump github.com/open-policy-agent/opa from 0.65.0 to 0.66.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/110 23 | * Bump github.com/open-policy-agent/opa from 0.66.0 to 0.69.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/118 24 | * Bump github.com/open-policy-agent/opa from 0.69.0 to 0.70.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/119 25 | * Bump github.com/hashicorp/hcl/v2 from 2.21.0 to 2.23.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/120 26 | * Bump actions/attest-build-provenance from 1 to 2 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/122 27 | * Bump github.com/zclconf/go-cty from 1.14.4 to 1.16.2 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/127 28 | * deps: Go 1.24 by @wata727 in https://github.com/terraform-linters/tflint-ruleset-opa/pull/130 29 | * Bump golang.org/x/net from 0.30.0 to 0.33.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/129 30 | * Bump github.com/open-policy-agent/opa from 0.70.0 to 1.2.0 by @dependabot in https://github.com/terraform-linters/tflint-ruleset-opa/pull/131 31 | * Enable Dependabot auto-merge by @wata727 in https://github.com/terraform-linters/tflint-ruleset-opa/pull/132 32 | * Add make release for release automation by @wata727 in https://github.com/terraform-linters/tflint-ruleset-opa/pull/137 33 | * Bump GoReleaser to v2 by @wata727 in https://github.com/terraform-linters/tflint-ruleset-opa/pull/138 34 | 35 | 36 | **Full Changelog**: https://github.com/terraform-linters/tflint-ruleset-opa/compare/v0.7.0...v0.8.0 37 | --------------------------------------------------------------------------------