├── testdata ├── config │ ├── blank_config.yml │ ├── invalid_structure.yml │ ├── yaml_extension.yaml │ ├── tag_keys.yml │ ├── tag_array.yml │ ├── empty_settings.yml │ ├── invalid_syntax.yml │ ├── missing_tags.yml │ ├── tag_values.yml │ └── full_config.yml ├── terraform │ ├── no_tags.tf │ ├── example_repo │ │ ├── variables.tf │ │ ├── locals.tf │ │ ├── main.tf │ │ └── provider.tf │ ├── ignore_all.tf │ ├── tags.tf │ ├── functions.tf │ ├── ignore.tf │ ├── referenced_tags.tf │ ├── provider.tf │ └── referenced_values.tf └── cloudformation │ ├── tags.yaml │ ├── tags.yml │ └── tags.json ├── img ├── demo.gif └── tag.png ├── internal ├── shared │ ├── types.go │ ├── helpers.go │ └── helpers_test.go ├── cloudformation │ ├── types.go │ ├── scan.go │ ├── helpers.go │ ├── spec_loader_test.go │ ├── spec_loader.go │ ├── resources.go │ ├── resources_test.go │ ├── helpers_test.go │ └── process.go ├── terraform │ ├── types.go │ ├── scan.go │ ├── default_tags_test.go │ ├── scan_test.go │ ├── references.go │ ├── helpers.go │ ├── default_tags.go │ ├── resources.go │ ├── process.go │ ├── helpers_test.go │ └── resources_test.go ├── inputs │ ├── loader.go │ ├── inputs_test.go │ ├── inputs.go │ └── loader_test.go └── config │ └── config.go ├── examples ├── gitlab.yml ├── codebuild.yml ├── .tag-nag.yml └── github.yml ├── .github └── workflows │ ├── semgrep.yml │ ├── go_tests.yml │ └── docker-hub.yml ├── .gitignore ├── go.mod ├── Dockerfile ├── LICENSE ├── main.go ├── go.sum ├── README.md └── main_test.go /testdata/config/blank_config.yml: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /testdata/config/invalid_structure.yml: -------------------------------------------------------------------------------- 1 | tags: "Owner,Project" 2 | -------------------------------------------------------------------------------- /testdata/config/yaml_extension.yaml: -------------------------------------------------------------------------------- 1 | tags: 2 | - key: Owner -------------------------------------------------------------------------------- /testdata/config/tag_keys.yml: -------------------------------------------------------------------------------- 1 | tags: 2 | - key: Owner 3 | - key: Project -------------------------------------------------------------------------------- /img/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakebark/tag-nag/HEAD/img/demo.gif -------------------------------------------------------------------------------- /img/tag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jakebark/tag-nag/HEAD/img/tag.png -------------------------------------------------------------------------------- /internal/shared/types.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | type TagMap map[string][]string 4 | -------------------------------------------------------------------------------- /testdata/config/tag_array.yml: -------------------------------------------------------------------------------- 1 | tags: 2 | - key: Owner 3 | values: "not-an-array" -------------------------------------------------------------------------------- /testdata/config/empty_settings.yml: -------------------------------------------------------------------------------- 1 | tags: 2 | - key: Owner 3 | 4 | settings: 5 | 6 | skip: -------------------------------------------------------------------------------- /testdata/config/invalid_syntax.yml: -------------------------------------------------------------------------------- 1 | tags: 2 | - key: Owner 3 | values: [Dev, Test 4 | -------------------------------------------------------------------------------- /testdata/config/missing_tags.yml: -------------------------------------------------------------------------------- 1 | settings: 2 | dry_run: true 3 | 4 | skip: 5 | - "*.tmp" -------------------------------------------------------------------------------- /testdata/terraform/no_tags.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "this" { 2 | bucket = "test-bucket" 3 | } 4 | 5 | -------------------------------------------------------------------------------- /testdata/terraform/example_repo/variables.tf: -------------------------------------------------------------------------------- 1 | variable "environment" { 2 | type = string 3 | default = "dev" 4 | } 5 | -------------------------------------------------------------------------------- /testdata/terraform/ignore_all.tf: -------------------------------------------------------------------------------- 1 | #tag-nag ignore-all 2 | resource "aws_s3_bucket" "this" { 3 | bucket = "test-bucket" 4 | } 5 | -------------------------------------------------------------------------------- /testdata/config/tag_values.yml: -------------------------------------------------------------------------------- 1 | tags: 2 | - key: Owner 3 | - key: Environment 4 | values: [Dev, Test, Prod] 5 | - key: Project -------------------------------------------------------------------------------- /testdata/terraform/tags.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "this" { 2 | bucket = "test-bucket" 3 | tags = { 4 | Owner = "jakebark" 5 | Environment = "dev" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /testdata/terraform/example_repo/locals.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | tags = { 3 | Owner = "jakebark" 4 | Environment = var.environment 5 | Source = "my-repo" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /testdata/terraform/functions.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "this" { 2 | bucket = "test-bucket" 3 | tags = { 4 | Owner = "jakebark" 5 | Environment = lower("Dev") 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /testdata/terraform/ignore.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "this" { 2 | #tag-nag ignore 3 | bucket = "test-bucket" 4 | tags = { 5 | Owner = "jakebark" 6 | Environment = "dev" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /internal/cloudformation/types.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | type Violation struct { 4 | resourceName string 5 | resourceType string 6 | line int 7 | missingTags []string 8 | skip bool 9 | } 10 | -------------------------------------------------------------------------------- /examples/gitlab.yml: -------------------------------------------------------------------------------- 1 | stages: 2 | - validate 3 | 4 | tag-nag: 5 | stage: validate 6 | image: jakebark/tag-nag:latest 7 | script: 8 | - terraform init -backend=false # remove for CloudFormation 9 | - tag-nag . --tags "tags" 10 | -------------------------------------------------------------------------------- /examples/codebuild.yml: -------------------------------------------------------------------------------- 1 | ## codebuild image 'jakebar/tag-nag:$latest' 2 | version: 0.2 3 | 4 | phases: 5 | build: 6 | commands: 7 | - cd "$CODEBUILD_SRC_DIR" 8 | - terraform init -backend=false # remove for CloudFormation 9 | - tag-nag . --tags "tags" 10 | -------------------------------------------------------------------------------- /testdata/terraform/referenced_tags.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "this" { 2 | bucket = "test-bucket" 3 | tags = var.tags 4 | } 5 | 6 | variable "tags" { 7 | type = map(string) 8 | default = { 9 | Owner = "jakebark" 10 | Environment = "dev" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /testdata/config/full_config.yml: -------------------------------------------------------------------------------- 1 | tags: 2 | - key: Owner 3 | - key: Environment 4 | values: [Dev, Test, Prod] 5 | - key: Project 6 | 7 | settings: 8 | case_insensitive: true 9 | dry_run: false 10 | cfn_spec: "/path/to/spec.json" 11 | 12 | skip: 13 | - "*.tmp" 14 | - ".terraform" 15 | - "test-data/**" -------------------------------------------------------------------------------- /testdata/cloudformation/tags.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: single resource 3 | Resources: 4 | this: 5 | Type: AWS::S3::Bucket 6 | Properties: 7 | BucketName: test-bucket 8 | Tags: 9 | - Key: Owner 10 | Value: jakebark 11 | - Key: Environment 12 | Value: dev 13 | -------------------------------------------------------------------------------- /testdata/cloudformation/tags.yml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: '2010-09-09' 2 | Description: single resource 3 | Resources: 4 | this: 5 | Type: AWS::S3::Bucket 6 | Properties: 7 | BucketName: test-bucket 8 | Tags: 9 | - Key: Owner 10 | Value: jakebark 11 | - Key: Environment 12 | Value: dev 13 | -------------------------------------------------------------------------------- /testdata/terraform/example_repo/main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "foo" { 2 | bucket = "test-bucket" 3 | } 4 | 5 | resource "aws_s3_bucket" "bar" { 6 | bucket = "test-bucket" 7 | tags = { 8 | Project = "112233" 9 | } 10 | } 11 | 12 | resource "aws_s3_bucket" "baz" { 13 | bucket = "test-bucket" 14 | provider = aws.west 15 | } 16 | -------------------------------------------------------------------------------- /testdata/terraform/provider.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | region = "us-east-1" 3 | default_tags { 4 | tags = { 5 | Project = "112233" 6 | Source = "my-repo" 7 | } 8 | } 9 | } 10 | 11 | resource "aws_s3_bucket" "this" { 12 | bucket = "test-bucket" 13 | tags = { 14 | Owner = "jakebark" 15 | Environment = "dev" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | name: semgrep 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | semgrep: 9 | runs-on: ubuntu-latest 10 | 11 | container: 12 | image: semgrep/semgrep 13 | 14 | steps: 15 | - name: checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: run semgrep 19 | run: semgrep ci 20 | -------------------------------------------------------------------------------- /testdata/terraform/example_repo/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "~> 5.0" 6 | } 7 | } 8 | } 9 | 10 | provider "aws" { 11 | region = "us-east-1" 12 | default_tags { 13 | tags = local.tags 14 | } 15 | } 16 | 17 | provider "aws" { 18 | alias = "west" 19 | region = "us-west-1" 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.exe 2 | *.exe~ 3 | *.dll 4 | *.so 5 | *.dylib 6 | 7 | *.test 8 | **/test/* 9 | todo.md 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | # Go workspace file 18 | go.work 19 | go.work.sum 20 | 21 | # env file 22 | .env 23 | 24 | .DS_Store 25 | -------------------------------------------------------------------------------- /examples/.tag-nag.yml: -------------------------------------------------------------------------------- 1 | tags: 2 | - key: Owner 3 | - key: Environment 4 | values: [Dev, Test, Prod] 5 | - key: Project 6 | 7 | settings: 8 | case_insensitive: false 9 | dry_run: false 10 | cfn_spec: "~/path/to/CloudFormationResourceSpecification.json" # remove if not using 11 | 12 | skip: 13 | - file.tf 14 | - "test-data/**" # keep quotes for wildcard/glob pattern 15 | - "*.tmp" 16 | - .terraform 17 | - .git 18 | 19 | 20 | -------------------------------------------------------------------------------- /.github/workflows/go_tests.yml: -------------------------------------------------------------------------------- 1 | name: go test 2 | 3 | on: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | jobs: 8 | test: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: checkout code 13 | uses: actions/checkout@v4 14 | 15 | - name: setup go 16 | uses: actions/setup-go@v5 17 | with: 18 | go-version: '1.22' 19 | cache: true 20 | 21 | - name: run go tests 22 | run: go test -v ./... 23 | -------------------------------------------------------------------------------- /internal/terraform/types.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "github.com/hashicorp/hcl/v2" 5 | "github.com/jakebark/tag-nag/internal/shared" 6 | ) 7 | 8 | type DefaultTags struct { 9 | LiteralTags map[string]shared.TagMap 10 | } 11 | 12 | type Violation struct { 13 | resourceType string 14 | resourceName string 15 | line int 16 | missingTags []string 17 | skip bool 18 | } 19 | 20 | type TerraformContext struct { 21 | EvalContext *hcl.EvalContext 22 | } 23 | -------------------------------------------------------------------------------- /examples/github.yml: -------------------------------------------------------------------------------- 1 | name: tag-nag 2 | 3 | on: 4 | pull_request: 5 | branches: [main] 6 | 7 | jobs: 8 | tag-nag: 9 | runs-on: ubuntu-latest 10 | 11 | container: 12 | image: jakebark/tag-nag:latest 13 | 14 | steps: 15 | - name: checkout code 16 | uses: actions/checkout@v4 17 | 18 | - name: run terraform init # remove for CloudFormation 19 | run: terraform init -backend=false 20 | 21 | - name: run tag-nag 22 | run: tag-nag . --tags "tags" 23 | -------------------------------------------------------------------------------- /testdata/terraform/referenced_values.tf: -------------------------------------------------------------------------------- 1 | resource "aws_s3_bucket" "this" { 2 | bucket = "test-bucket" 3 | tags = { 4 | Owner = var.owner 5 | Environment = local.environment 6 | Project = "${local.project}" 7 | Source = "${local.source}" 8 | } 9 | } 10 | 11 | variable "owner" { 12 | type = string 13 | default = "jakebark" 14 | } 15 | 16 | variable "source" { 17 | type = string 18 | default = "my-repo" 19 | } 20 | 21 | locals { 22 | environment = "dev" 23 | project = "112233" 24 | source = var.source 25 | } 26 | -------------------------------------------------------------------------------- /testdata/cloudformation/tags.json: -------------------------------------------------------------------------------- 1 | { 2 | "AWSTemplateFormatVersion": "2010-09-09", 3 | "Description": "single resource", 4 | "Resources": { 5 | "this": { 6 | "Type": "AWS::S3::Bucket", 7 | "Properties": { 8 | "BucketName": "test-bucket", 9 | "Tags": [ 10 | { 11 | "Key": "Owner", 12 | "Value": "jakebark" 13 | }, 14 | { 15 | "Key": "Environment", 16 | "Value": "dev" 17 | } 18 | ] 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /internal/terraform/scan.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "path/filepath" 7 | ) 8 | 9 | // scan looks for tf files 10 | func scan(dirPath string) (bool, error) { 11 | found := false 12 | targetExt := ".tf" 13 | 14 | walkErr := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error { 15 | if err != nil { 16 | return nil 17 | } 18 | 19 | if !d.IsDir() { 20 | if filepath.Ext(path) == targetExt { 21 | found = true 22 | return fs.ErrNotExist // stop scan immediately 23 | } 24 | } 25 | return nil 26 | }) 27 | 28 | if walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) { 29 | return false, walkErr 30 | } 31 | 32 | return found, nil 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jakebark/tag-nag 2 | 3 | go 1.22 4 | 5 | require ( 6 | github.com/hashicorp/hcl/v2 v2.23.0 7 | github.com/spf13/pflag v1.0.6 8 | github.com/zclconf/go-cty v1.13.0 9 | gopkg.in/yaml.v3 v3.0.1 10 | ) 11 | 12 | require ( 13 | github.com/agext/levenshtein v1.2.1 // indirect 14 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 15 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 16 | github.com/google/go-cmp v0.6.0 // indirect 17 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect 18 | golang.org/x/mod v0.8.0 // indirect 19 | golang.org/x/sys v0.5.0 // indirect 20 | golang.org/x/text v0.11.0 // indirect 21 | golang.org/x/tools v0.6.0 // indirect 22 | ) 23 | -------------------------------------------------------------------------------- /internal/cloudformation/scan.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "path/filepath" 7 | ) 8 | 9 | // scan looks for cfn files 10 | func scan(dirPath string) (bool, error) { 11 | found := false 12 | targetExts := map[string]bool{ 13 | ".yaml": true, 14 | ".yml": true, 15 | ".json": true, 16 | } 17 | walkErr := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error { 18 | if err != nil { 19 | return nil 20 | } 21 | 22 | if !d.IsDir() { 23 | if targetExts[filepath.Ext(path)] { 24 | found = true 25 | return fs.ErrNotExist // stop scan immediately 26 | } 27 | } 28 | return nil 29 | }) 30 | 31 | if walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) { 32 | return false, walkErr 33 | } 34 | return found, nil 35 | } 36 | -------------------------------------------------------------------------------- /internal/terraform/default_tags_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | // Test for normalizeProviderID 8 | func TestNormalizeProviderID(t *testing.T) { 9 | tests := []struct { 10 | name string 11 | providerName string 12 | alias string 13 | caseInsensitive bool 14 | expected string 15 | }{ 16 | { 17 | name: "default", 18 | providerName: "aws", 19 | alias: "", 20 | expected: "aws", 21 | }, 22 | { 23 | name: "alias", 24 | providerName: "aws", 25 | alias: "west", 26 | expected: "aws.west", 27 | }, 28 | } 29 | 30 | for _, tc := range tests { 31 | t.Run(tc.name, func(t *testing.T) { 32 | if got := normalizeProviderID(tc.providerName, tc.alias, tc.caseInsensitive); got != tc.expected { 33 | t.Errorf("normalizeProviderID() = %v, expected %v", got, tc.expected) 34 | } 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22 AS builder 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod go.sum ./ 6 | RUN go mod download 7 | RUN go mod tidy 8 | 9 | COPY . . 10 | 11 | RUN go build -o tag-nag 12 | 13 | FROM debian:stable-slim 14 | 15 | ARG TERRAFORM_VERSION 16 | 17 | RUN apt-get update && \ 18 | apt-get install -y --no-install-recommends \ 19 | wget \ 20 | unzip \ 21 | ca-certificates \ 22 | git && \ 23 | rm -rf /var/lib/apt/lists/* 24 | 25 | RUN wget "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \ 26 | unzip "terraform_${TERRAFORM_VERSION}_linux_amd64.zip" -d /usr/local/bin && \ 27 | rm "terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \ 28 | chmod +x /usr/local/bin/terraform 29 | 30 | RUN groupadd -r appgroup && \ 31 | useradd --no-log-init -r -g appgroup appuser 32 | 33 | COPY --from=builder /app/tag-nag /usr/local/bin/tag-nag 34 | 35 | USER appuser 36 | 37 | WORKDIR /workspace 38 | 39 | ENTRYPOINT ["tag-nag"] 40 | 41 | CMD ["--help"] 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 jakebark 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.github/workflows/docker-hub.yml: -------------------------------------------------------------------------------- 1 | name: publish to docker hub 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build-and-push: 9 | runs-on: ubuntu-latest 10 | permissions: 11 | contents: read 12 | steps: 13 | - name: Check out the repo 14 | uses: actions/checkout@v4 15 | 16 | - name: Log in to Docker Hub 17 | uses: docker/login-action@v3 18 | with: 19 | username: ${{ secrets.DOCKER_USERNAME }} 20 | password: ${{ secrets.DOCKER_PASSWORD }} 21 | 22 | - name: Extract metadata (tags, labels) for Docker 23 | id: meta 24 | uses: docker/metadata-action@v5 25 | with: 26 | images: jakebark/tag-nag 27 | 28 | - name: Build and push Docker image 29 | id: push 30 | uses: docker/build-push-action@v6 31 | with: 32 | context: . 33 | file: ./Dockerfile 34 | push: true 35 | tags: ${{ steps.meta.outputs.tags }} 36 | labels: ${{ steps.meta.outputs.labels }} 37 | build-args: | 38 | TERRAFORM_VERSION=${{ vars.TERRAFORM_VERSION }} 39 | 40 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/jakebark/tag-nag/internal/cloudformation" 8 | "github.com/jakebark/tag-nag/internal/inputs" 9 | "github.com/jakebark/tag-nag/internal/terraform" 10 | ) 11 | 12 | func main() { 13 | log.SetFlags(0) // remove timestamp from prints 14 | 15 | userInput := inputs.ParseFlags() 16 | 17 | if userInput.DryRun { 18 | log.Printf("\033[32mDry-run: %s\033[0m\n", userInput.Directory) 19 | } else { 20 | log.Printf("\033[33mScanning: %s\033[0m\n", userInput.Directory) 21 | } 22 | 23 | tfViolations := terraform.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.Skip) 24 | cfnViolations := cloudformation.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.CfnSpecPath, userInput.Skip) 25 | 26 | violations := tfViolations + cfnViolations 27 | 28 | if violations > 0 && userInput.DryRun { 29 | log.Printf("\033[32mFound %d tag violation(s)\033[0m\n", violations) 30 | os.Exit(0) 31 | } else if violations > 0 { 32 | log.Printf("\033[31mFound %d tag violation(s)\033[0m\n", violations) 33 | os.Exit(1) 34 | } else { 35 | log.Println("No tag violations found") 36 | os.Exit(0) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/cloudformation/helpers.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/jakebark/tag-nag/internal/config" 8 | "gopkg.in/yaml.v3" 9 | ) 10 | 11 | // mapNodes converts a yaml mapping node into a go map 12 | func mapNodes(node *yaml.Node) map[string]*yaml.Node { 13 | m := make(map[string]*yaml.Node) 14 | if node == nil || node.Kind != yaml.MappingNode { 15 | return m 16 | } 17 | for i := 0; i < len(node.Content); i += 2 { 18 | keyNode := node.Content[i] 19 | valueNode := node.Content[i+1] 20 | m[keyNode.Value] = valueNode 21 | } 22 | return m 23 | } 24 | 25 | // findMapNode parses a yaml block and returns the value, when given the key 26 | func findMapNode(node *yaml.Node, key string) *yaml.Node { 27 | if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { 28 | node = node.Content[0] 29 | } 30 | if node.Kind != yaml.MappingNode { 31 | return nil 32 | } 33 | for i := 0; i < len(node.Content); i += 2 { 34 | k := node.Content[i] 35 | v := node.Content[i+1] 36 | if k.Value == key { 37 | return v 38 | } 39 | } 40 | return nil 41 | } 42 | 43 | // parseYAML unmarshal yaml and return a pointer to the root of the node 44 | func parseYAML(filePath string) (*yaml.Node, error) { 45 | data, err := os.ReadFile(filePath) 46 | if err != nil { 47 | return nil, err 48 | } 49 | 50 | var root yaml.Node 51 | if err := yaml.Unmarshal(data, &root); err != nil { 52 | return nil, err 53 | } 54 | 55 | if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { 56 | root = *root.Content[0] 57 | } 58 | return &root, nil 59 | } 60 | 61 | func skipResource(node *yaml.Node, lines []string) bool { 62 | index := node.Line - 2 63 | if index < len(lines) { 64 | if strings.Contains(lines[index], config.TagNagIgnore) { 65 | return true 66 | } 67 | } 68 | return false 69 | } 70 | -------------------------------------------------------------------------------- /internal/inputs/loader.go: -------------------------------------------------------------------------------- 1 | package inputs 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/jakebark/tag-nag/internal/config" 8 | "github.com/jakebark/tag-nag/internal/shared" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | type Config struct { 13 | Tags []TagDefinition `yaml:"tags"` 14 | Settings Settings `yaml:"settings"` 15 | Skip []string `yaml:"skip"` 16 | } 17 | 18 | type TagDefinition struct { 19 | Key string `yaml:"key"` 20 | Values []string `yaml:"values,omitempty"` 21 | } 22 | 23 | type Settings struct { 24 | CaseInsensitive bool `yaml:"case_insensitive"` 25 | DryRun bool `yaml:"dry_run"` 26 | CfnSpec string `yaml:"cfn_spec"` 27 | } 28 | 29 | // FindAndLoadConfigFile attempts to find and load configuration file 30 | func FindAndLoadConfigFile() (*Config, error) { 31 | if _, err := os.Stat(config.DefaultConfigFile); err == nil { 32 | return processConfigFile(config.DefaultConfigFile) 33 | } 34 | 35 | if _, err := os.Stat(config.AltConfigFile); err == nil { 36 | return processConfigFile(config.AltConfigFile) 37 | } 38 | 39 | return nil, nil 40 | } 41 | 42 | // processConfigFile reads the config file 43 | func processConfigFile(path string) (*Config, error) { 44 | data, err := os.ReadFile(path) 45 | if err != nil { 46 | return nil, fmt.Errorf("reading config file %s: %w", path, err) 47 | } 48 | 49 | var config Config 50 | if err := yaml.Unmarshal(data, &config); err != nil { 51 | return nil, fmt.Errorf("parsing config file %s: %w", path, err) 52 | } 53 | 54 | return &config, nil 55 | } 56 | 57 | // ConvertToTagMap converts config tags to internal TagMap format 58 | func (c *Config) convertToTagMap() shared.TagMap { 59 | tagMap := make(shared.TagMap) 60 | 61 | for _, tag := range c.Tags { 62 | tagMap[tag.Key] = tag.Values 63 | } 64 | 65 | return tagMap 66 | } 67 | -------------------------------------------------------------------------------- /internal/cloudformation/spec_loader_test.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | ) 7 | 8 | // helper to create a test spec file 9 | func createTestSpec() *cfnSpec { 10 | specJSON := `{ 11 | "PropertyTypes": { 12 | "Tag": { 13 | "Properties": { 14 | "Key": { "PrimitiveType": "String", "Required": true }, 15 | "Value": { "PrimitiveType": "String", "Required": true } 16 | } 17 | }, 18 | "OtherType": { 19 | "Properties": { "Name": { "PrimitiveType": "String" } } 20 | } 21 | }, 22 | "ResourceTypes": {} 23 | }` 24 | var spec cfnSpec 25 | if err := json.Unmarshal([]byte(specJSON), &spec); err != nil { 26 | panic("Failed to unmarshal base test spec data: " + err.Error()) 27 | } 28 | return &spec 29 | } 30 | 31 | func TestIsTaggable(t *testing.T) { 32 | baseSpec := createTestSpec() 33 | 34 | tests := []struct { 35 | name string 36 | resourceJSON string 37 | want bool 38 | }{ 39 | { 40 | name: "taggable", 41 | resourceJSON: `{ 42 | "Properties": { 43 | "Name": { "PrimitiveType": "String" }, 44 | "Tags": { "Type": "List", "ItemType": "Tag", "Required": false } 45 | } 46 | }`, 47 | want: true, 48 | }, 49 | { 50 | name: "not taggable", 51 | resourceJSON: `{ 52 | "Properties": { 53 | "Name": { "PrimitiveType": "String" } 54 | } 55 | }`, 56 | want: false, 57 | }, 58 | } 59 | 60 | for _, tc := range tests { 61 | t.Run(tc.name, func(t *testing.T) { 62 | var resourceDef cfnResourceType 63 | if err := json.Unmarshal([]byte(tc.resourceJSON), &resourceDef); err != nil { 64 | t.Fatalf("Failed to unmarshal test resource JSON: %v", err) 65 | } 66 | 67 | if got := resourceDef.isTaggable(baseSpec); got != tc.want { 68 | t.Errorf("isTaggable() = %v, want %v", got, tc.want) 69 | } 70 | }) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /internal/shared/helpers.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | ) 8 | 9 | // FilterMissingTags checks effectiveTags against requiredTags 10 | func FilterMissingTags(requiredTags TagMap, effectiveTags TagMap, caseInsensitive bool) []string { 11 | var missingTags []string 12 | 13 | for reqKey, allowedValues := range requiredTags { 14 | effectiveValues, keyFound := matchTagKey(reqKey, effectiveTags, caseInsensitive) 15 | 16 | // construct violation message 17 | violationMessage := reqKey 18 | if len(allowedValues) > 0 { 19 | violationMessage = fmt.Sprintf("%s[%s]", reqKey, strings.Join(allowedValues, ",")) 20 | } 21 | if !keyFound { 22 | missingTags = append(missingTags, violationMessage) 23 | continue 24 | } 25 | 26 | // if there are tag values required, check them 27 | if len(allowedValues) > 0 { 28 | if !matchTagValue(allowedValues, effectiveValues, caseInsensitive) { 29 | missingTags = append(missingTags, violationMessage) 30 | } 31 | } 32 | } 33 | 34 | sort.Strings(missingTags) // 35 | return missingTags 36 | } 37 | 38 | // matchTagKey checks required tag key against effective tags 39 | func matchTagKey(reqKey string, effectiveTags TagMap, caseInsensitive bool) (values []string, found bool) { 40 | for effKey, effValues := range effectiveTags { 41 | if caseInsensitive { 42 | if strings.EqualFold(effKey, reqKey) { 43 | return effValues, true 44 | } 45 | } else { 46 | if effKey == reqKey { 47 | return effValues, true 48 | } 49 | } 50 | } 51 | return nil, false 52 | } 53 | 54 | // matchTagValue checks required tag values (if present) against effective tags 55 | func matchTagValue(allowedValues []string, effectiveValues []string, caseInsensitive bool) bool { 56 | if len(allowedValues) == 0 { // if no tag alues are required, return match 57 | return true 58 | } 59 | if len(effectiveValues) == 0 && len(allowedValues) > 0 { 60 | return false 61 | } 62 | 63 | for _, allowed := range allowedValues { 64 | for _, effVal := range effectiveValues { 65 | if caseInsensitive { 66 | if strings.EqualFold(effVal, allowed) { 67 | return true 68 | } 69 | } else { 70 | if effVal == allowed { 71 | return true 72 | } 73 | } 74 | } 75 | } 76 | return false 77 | } 78 | -------------------------------------------------------------------------------- /internal/cloudformation/spec_loader.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | type cfnSpec struct { 12 | ResourceTypes map[string]cfnResourceType `json:"ResourceTypes"` 13 | PropertyTypes map[string]cfnPropertyType `json:"PropertyTypes"` 14 | } 15 | 16 | type cfnResourceType struct { 17 | Properties map[string]cfnProperty `json:"Properties"` 18 | } 19 | 20 | type cfnProperty struct { 21 | Required bool `json:"Required"` 22 | Type string `json:"Type"` // list 23 | ItemType string `json:"ItemType"` // tag 24 | PrimitiveType string `json:"PrimitiveType"` 25 | PrimitiveItemType string `json:"PrimitiveItemType"` 26 | // Other fields ignored 27 | } 28 | 29 | type cfnPropertyType struct { 30 | Properties map[string]cfnProperty `json:"Properties"` 31 | } 32 | 33 | // isTaggable checks the cfn spec file to see if a resource can be tagged 34 | func (rt cfnResourceType) isTaggable(specData *cfnSpec) bool { 35 | tagsProp, ok := rt.Properties["Tags"] 36 | if !ok { 37 | return false 38 | } 39 | if tagsProp.Type == "List" && tagsProp.ItemType == "Tag" { 40 | tagTypeDef, tagTypeExists := specData.PropertyTypes["Tag"] 41 | if tagTypeExists { 42 | _, keyExists := tagTypeDef.Properties["Key"] 43 | _, valueExists := tagTypeDef.Properties["Value"] 44 | return keyExists && valueExists 45 | } 46 | } 47 | return false 48 | } 49 | 50 | // LoadTaggableResourcesFromSpec parses the provided spec file path 51 | func loadTaggableResourcesFromSpec(specFilePath string) (map[string]bool, error) { 52 | log.Printf("Attempting to load CloudFormation specification from: %s", specFilePath) 53 | data, err := os.ReadFile(specFilePath) 54 | if err != nil { 55 | return nil, fmt.Errorf("failed to read CloudFormation spec file '%s': %w", specFilePath, err) 56 | } 57 | 58 | var specData cfnSpec 59 | err = json.Unmarshal(data, &specData) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to parse CloudFormation spec JSON from '%s': %w", specFilePath, err) 62 | } 63 | 64 | if specData.ResourceTypes == nil { 65 | return nil, fmt.Errorf("invalid CloudFormation spec format: missing 'ResourceTypes' in '%s'", specFilePath) 66 | } 67 | if specData.PropertyTypes == nil { 68 | return nil, fmt.Errorf("invalid CloudFormation spec format: missing 'PropertyTypes' in '%s'", specFilePath) 69 | } 70 | 71 | taggableMap := make(map[string]bool) 72 | for resourceName, resourceDef := range specData.ResourceTypes { 73 | if strings.HasPrefix(resourceName, "AWS::") { 74 | taggableMap[resourceName] = resourceDef.isTaggable(&specData) 75 | } 76 | } 77 | 78 | log.Printf("Loaded %d AWS resource types.", len(taggableMap)) 79 | return taggableMap, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/zclconf/go-cty/cty/function" 5 | "github.com/zclconf/go-cty/cty/function/stdlib" 6 | ) 7 | 8 | const ( 9 | TagNagIgnore = "#tag-nag ignore" 10 | TagNagIgnoreAll = "#tag-nag ignore-all" 11 | DefaultConfigFile = ".tag-nag.yml" 12 | AltConfigFile = ".tag-nag.yaml" 13 | ) 14 | 15 | var SkippedDirs = []string{ 16 | ".terraform", 17 | ".git", 18 | } 19 | 20 | // terraform functions, used when evaluating context of locals and vars 21 | // added manually, no reasonable workaround to auto-import all 22 | // https://developer.hashicorp.com/terraform/language/functions 23 | // https://pkg.go.dev/github.com/zclconf/go-cty@v1.16.2/cty/function/stdlib 24 | var StdlibFuncs = map[string]function.Function{ 25 | "abs": stdlib.AbsoluteFunc, 26 | "ceil": stdlib.CeilFunc, 27 | "chomp": stdlib.ChompFunc, 28 | "chunklist": stdlib.ChunklistFunc, 29 | "coalesce": stdlib.CoalesceFunc, 30 | "coalescelist": stdlib.CoalesceListFunc, 31 | "compact": stdlib.CompactFunc, 32 | "concat": stdlib.ConcatFunc, 33 | "contains": stdlib.ContainsFunc, 34 | "csvdecode": stdlib.CSVDecodeFunc, 35 | "distinct": stdlib.DistinctFunc, 36 | "element": stdlib.ElementFunc, 37 | "flatten": stdlib.FlattenFunc, 38 | "floor": stdlib.FloorFunc, 39 | "format": stdlib.FormatFunc, 40 | "formatdate": stdlib.FormatDateFunc, 41 | "formatlist": stdlib.FormatListFunc, 42 | "indent": stdlib.IndentFunc, 43 | "index": stdlib.IndexFunc, 44 | "int": stdlib.IntFunc, 45 | "join": stdlib.JoinFunc, 46 | "jsondecode": stdlib.JSONDecodeFunc, 47 | "jsonencode": stdlib.JSONEncodeFunc, 48 | "keys": stdlib.KeysFunc, 49 | "length": stdlib.LengthFunc, 50 | "log": stdlib.LogFunc, 51 | "lookup": stdlib.LookupFunc, 52 | "lower": stdlib.LowerFunc, 53 | "max": stdlib.MaxFunc, 54 | "merge": stdlib.MergeFunc, 55 | "min": stdlib.MinFunc, 56 | "parseint": stdlib.ParseIntFunc, 57 | "pow": stdlib.PowFunc, 58 | "range": stdlib.RangeFunc, 59 | "regex": stdlib.RegexFunc, 60 | "regexall": stdlib.RegexAllFunc, 61 | "regexreplace": stdlib.RegexReplaceFunc, 62 | "replace": stdlib.ReplaceFunc, 63 | "reverse": stdlib.ReverseFunc, 64 | "reverselist": stdlib.ReverseListFunc, 65 | "setunion": stdlib.SetUnionFunc, 66 | "slice": stdlib.SliceFunc, 67 | "sort": stdlib.SortFunc, 68 | "split": stdlib.SplitFunc, 69 | "trim": stdlib.TrimFunc, 70 | "trimprefix": stdlib.TrimPrefixFunc, 71 | "trimspace": stdlib.TrimSpaceFunc, 72 | "trimsuffix": stdlib.TrimSuffixFunc, 73 | "upper": stdlib.UpperFunc, 74 | "values": stdlib.ValuesFunc, 75 | } 76 | -------------------------------------------------------------------------------- /internal/terraform/scan_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "errors" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestScanFunction(t *testing.T) { 12 | createDummyFile := func(t *testing.T, path string) { 13 | t.Helper() 14 | err := os.WriteFile(path, []byte("dummy content"), 0644) 15 | if err != nil { 16 | t.Fatalf("Failed to create dummy file %s: %v", path, err) 17 | } 18 | } 19 | 20 | tests := []struct { 21 | name string 22 | setupDir func(t *testing.T, dir string) 23 | expected bool 24 | }{ 25 | { 26 | name: "empty dir", 27 | setupDir: func(t *testing.T, dir string) { 28 | // dir is empty 29 | }, 30 | expected: false, 31 | }, 32 | { 33 | name: "no terraform files", 34 | setupDir: func(t *testing.T, dir string) { 35 | createDummyFile(t, filepath.Join(dir, "main.yaml")) 36 | createDummyFile(t, filepath.Join(dir, "README.md")) 37 | }, 38 | expected: false, 39 | }, 40 | { 41 | name: "terraform file", 42 | setupDir: func(t *testing.T, dir string) { 43 | createDummyFile(t, filepath.Join(dir, "main.tf")) 44 | createDummyFile(t, filepath.Join(dir, "other.txt")) 45 | }, 46 | expected: true, 47 | }, 48 | { 49 | name: "multiple terraform files", 50 | setupDir: func(t *testing.T, dir string) { 51 | createDummyFile(t, filepath.Join(dir, "main.tf")) 52 | createDummyFile(t, filepath.Join(dir, "variables.tf")) 53 | }, 54 | expected: true, 55 | }, 56 | { 57 | name: "nested terraform file", 58 | setupDir: func(t *testing.T, dir string) { 59 | subDir := filepath.Join(dir, "subdir") 60 | err := os.Mkdir(subDir, 0755) 61 | if err != nil { 62 | t.Fatalf("Failed to create subdir: %v", err) 63 | } 64 | createDummyFile(t, filepath.Join(subDir, "module.tf")) 65 | createDummyFile(t, filepath.Join(dir, "root.txt")) 66 | }, 67 | expected: true, 68 | }, 69 | { 70 | name: "terraform file, nested non-terraform file", 71 | setupDir: func(t *testing.T, dir string) { 72 | subDir := filepath.Join(dir, "subdir") 73 | err := os.Mkdir(subDir, 0755) 74 | if err != nil { 75 | t.Fatalf("Failed to create subdir: %v", err) 76 | } 77 | createDummyFile(t, filepath.Join(dir, "main.tf")) 78 | createDummyFile(t, filepath.Join(subDir, "other.yaml")) 79 | }, 80 | expected: true, 81 | }, 82 | } 83 | 84 | for _, tc := range tests { 85 | t.Run(tc.name, func(t *testing.T) { 86 | tmpDir := t.TempDir() 87 | testPath := tmpDir 88 | if tc.name == "Non-existent directory" { 89 | testPath = filepath.Join(tmpDir, "this_dir_should_not_exist") 90 | } else { 91 | tc.setupDir(t, tmpDir) 92 | } 93 | gotFound, gotErr := scan(testPath) 94 | if gotErr != nil && !errors.Is(gotErr, fs.ErrNotExist) { 95 | if tc.name != "Non-existent directory" { 96 | t.Logf("scan() returned an unexpected error: %v (test will check 'found' status only)", gotErr) 97 | } 98 | } 99 | if gotFound != tc.expected { 100 | t.Errorf("scan() found = %v, expected %v", gotFound, tc.expected) 101 | } 102 | }) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /internal/cloudformation/resources.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/jakebark/tag-nag/internal/shared" 9 | "gopkg.in/yaml.v3" 10 | ) 11 | 12 | // getResourceViolations inspects resource blocks and returns violations 13 | func checkResourcesforTags(resourcesMapping map[string]*yaml.Node, requiredTags shared.TagMap, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool) []Violation { 14 | var violations []Violation 15 | 16 | for resourceName, resourceNode := range resourcesMapping { // resourceNode == yaml node for resource 17 | resourceMapping := mapNodes(resourceNode) 18 | 19 | typeNode, ok := resourceMapping["Type"] 20 | if !ok || !strings.HasPrefix(typeNode.Value, "AWS::") { 21 | continue 22 | } 23 | resourceType := typeNode.Value 24 | 25 | if taggable != nil { 26 | isTaggable, found := taggable[resourceType] 27 | if found && !isTaggable { 28 | continue 29 | } 30 | } 31 | 32 | properties := make(map[string]interface{}) // tags are part of the properties node 33 | if propsNode, ok := resourceMapping["Properties"]; ok { 34 | _ = propsNode.Decode(&properties) 35 | } 36 | 37 | tags, err := extractTagMap(properties, caseInsensitive) 38 | if err != nil { 39 | log.Printf("Error extracting tags from resource %s: %v\n", resourceName, err) 40 | continue 41 | } 42 | 43 | missing := shared.FilterMissingTags(requiredTags, tags, caseInsensitive) 44 | if len(missing) > 0 { 45 | violation := Violation{ 46 | resourceName: resourceName, 47 | resourceType: resourceType, 48 | line: resourceNode.Line, 49 | missingTags: missing, 50 | } 51 | // if file-level or resource-level ignore is found 52 | if skipAll || skipResource(resourceNode, fileLines) { 53 | violation.skip = true 54 | } 55 | violations = append(violations, violation) 56 | } 57 | } 58 | return violations 59 | } 60 | 61 | // extractTagMap extracts a yaml/json map to a go map 62 | func extractTagMap(properties map[string]interface{}, caseInsensitive bool) (shared.TagMap, error) { 63 | tagsMap := make(shared.TagMap) 64 | literalTags, exists := properties["Tags"] 65 | if !exists { 66 | return tagsMap, nil 67 | } 68 | 69 | tagsList, ok := literalTags.([]interface{}) 70 | if !ok { 71 | return tagsMap, fmt.Errorf("Tags format is invalid") // tags are not in a list 72 | } 73 | 74 | for _, tagInterface := range tagsList { 75 | tagEntry, ok := tagInterface.(map[string]interface{}) 76 | if !ok { 77 | continue 78 | } 79 | key, ok := tagEntry["Key"].(string) 80 | if !ok { 81 | continue 82 | } 83 | var tagValue string 84 | if valStr, ok := tagEntry["Value"].(string); ok { 85 | tagValue = valStr 86 | } else if refMap, ok := tagEntry["Value"].(map[string]interface{}); ok { 87 | if ref, exists := refMap["Ref"]; exists { 88 | if refStr, ok := ref.(string); ok { 89 | tagValue = fmt.Sprintf("!Ref %s", refStr) 90 | } 91 | } 92 | } 93 | if caseInsensitive { 94 | key = strings.ToLower(key) 95 | } 96 | tagsMap[key] = []string{tagValue} 97 | } 98 | return tagsMap, nil 99 | } 100 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= 2 | github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 3 | github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= 4 | github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= 5 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 6 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= 10 | github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= 14 | github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= 15 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= 16 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= 17 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 18 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 19 | github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= 20 | github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= 21 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= 22 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= 23 | golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= 24 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 25 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 26 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 27 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= 28 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= 30 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 31 | golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= 32 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tag-nag 2 | 3 | 4 | 5 | Validate AWS tags in Terraform and CloudFormation. 6 | 7 | ## Installation 8 | ```bash 9 | go install github.com/jakebark/tag-nag@latest 10 | ``` 11 | You may need to set [GOPATH](https://go.dev/wiki/SettingGOPATH). 12 | 13 | ## Commands 14 | 15 | Tag-nag will search a file or directory for tag keys. Directory search is recursive. 16 | 17 | ```bash 18 | tag-nag --tags "Key1,Key2" 19 | 20 | tag-nag main.tf --tags "Owner" # run against a file 21 | tag-nag ./my_project --tags "Owner,Environment" # run against a directory 22 | tag-nag . --tags "Owner", "Environment" # will take string or list 23 | 24 | ``` 25 | 26 | Search for tag keys *and* values 27 | 28 | ```bash 29 | tag-nag --tags "Key[Value]" 30 | 31 | tag-nag main.tf --tags "Owner[Jake]" 32 | tag-nag main.tf --tags "Owner[Jake],Environment" # mixed search possible 33 | tag-nag main.tf --tags "Owner[Jake],Environment[Dev,Prod]" # multiple options for tag values 34 | 35 | ``` 36 | 37 | Optional flags 38 | ```bash 39 | -c # case-insensitive 40 | -d # dry-run (will always exit successfully) 41 | ``` 42 | 43 | Optional inputs 44 | ```bash 45 | --cfn-spec ~/path/to/CloudFormationResourceSpecification.json # path to Cfn spec file, filters taggable resources 46 | --skip "file.tf, path/to/directory" # skip files and directories 47 | ``` 48 | 49 | ## Config file 50 | 51 | The above commands can be issued with a `.tag-nag.yml` file in the same directory where tag-nag is run. 52 | 53 | See the [example .tag-nag.yml file](./examples/.tag-nag.yml). 54 | 55 | ## Skip Checks 56 | 57 | Skip file 58 | ```hcl 59 | #tag-nag ignore-all 60 | ``` 61 | 62 | Terraform 63 | ```hcl 64 | resource "aws_s3_bucket" "this" { 65 | #tag-nag ignore 66 | bucket = "that" 67 | } 68 | ``` 69 | 70 | CloudFormation 71 | ```yaml 72 | EC2Instance: #tag-nag ignore 73 | Type: "AWS::EC2::Instance" 74 | Properties: 75 | ImageId: ami-12a34b 76 | InstanceType: c1.xlarge 77 | ``` 78 | 79 | ## Filtering taggable resources 80 | 81 | Some AWS resources cannot be tagged. 82 | 83 | To filter out these resources with Terraform, run tag-nag against an initialised directory (`terraform init`). 84 | 85 | To filter out these resources with CloudFormation, specify a path to the [CloudFormation JSON spec file](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-resource-specification.html) with the `--cfn-spec` input. 86 | 87 | ## Docker 88 | Run 89 | ```bash 90 | docker pull jakebark/tag-nag:latest 91 | docker run --rm -v $(pwd):/workspace -w /workspace jakebark/tag-nag \ 92 | . --tags "Owner,Environment" 93 | 94 | ``` 95 | 96 | Interactive shell 97 | ```bash 98 | docker pull jakebark/tag-nag:latest 99 | docker run -it --rm \ 100 | -v "$(pwd)":/workspace \ 101 | -w /workspace \ 102 | --entrypoint /bin/sh jakebark/tag-nag:latest 103 | ``` 104 | 105 | The image contains terraform, allowing `terraform init` to be run, if required. 106 | ```bash 107 | docker pull jakebark/tag-nag:latest 108 | docker run --rm -v $(pwd):/workspace -w /workspace \ 109 | --entrypoint /bin/sh jakebark/tag-nag:latest \ 110 | -c "terraform init -input=false -no-color && tag-nag\ 111 | . --tags 'Owner,Environment'" 112 | ``` 113 | 114 | ## CI/CD 115 | 116 | Example CI files: 117 | - [GitHub](./examples/github.yml) 118 | - [GitLab](./examples/gitlab.yml) 119 | - [AWS CodeBuild](./examples/codebuild.yml) 120 | 121 | ## Related Resources 122 | 123 | - [pkg.go.dev/github.com/jakebark/tag-nag](https://pkg.go.dev/github.com/jakebark/tag-nag) 124 | 125 |
126 | tag:nag 127 |
128 | -------------------------------------------------------------------------------- /internal/cloudformation/resources_test.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | "github.com/jakebark/tag-nag/internal/shared" 8 | ) 9 | 10 | func TestExtractTagMap(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | properties map[string]interface{} 14 | caseInsensitive bool 15 | expected shared.TagMap 16 | expectedErr bool 17 | }{ 18 | { 19 | name: "no tag key", 20 | properties: map[string]interface{}{ 21 | "OtherKey": "Value"}, 22 | expected: shared.TagMap{}, 23 | }, 24 | { 25 | name: "empty list", 26 | properties: map[string]interface{}{ 27 | "Tags": []interface{}{}, 28 | }, 29 | expected: shared.TagMap{}, 30 | }, 31 | { 32 | name: "not a list", 33 | properties: map[string]interface{}{ 34 | "Tags": map[string]string{"Key": "Value"}, 35 | }, 36 | expectedErr: true, 37 | }, 38 | { 39 | name: "literal tags", 40 | properties: map[string]interface{}{ 41 | "Tags": []interface{}{ 42 | map[string]interface{}{"Key": "Owner", "Value": "Jake"}, 43 | map[string]interface{}{"Key": "Env", "Value": "Dev"}, 44 | }, 45 | }, 46 | expected: shared.TagMap{ 47 | "Owner": []string{"Jake"}, 48 | "Env": []string{"Dev"}, 49 | }, 50 | }, 51 | // { 52 | // name: "referenced tags", 53 | // properties: map[string]interface{}{ 54 | // "Tags": []interface{}{ 55 | // map[string]interface{}{"Key": "StackName", "Value": map[string]interface{}{"Ref": "AWS::StackName"}}, 56 | // }, 57 | // }, 58 | // expected: shared.TagMap{ 59 | // "StackName": []string{"!Ref StackName"}, 60 | // }, 61 | // }, 62 | // { 63 | // name: "mixed tags, literal and referenced", 64 | // properties: map[string]interface{}{ 65 | // "Tags": []interface{}{ 66 | // map[string]interface{}{"Key": "Owner", "Value": "Jake"}, 67 | // map[string]interface{}{"Key": "StackName", "Value": map[string]interface{}{"Ref": "AWS::StackName"}}, 68 | // }, 69 | // }, 70 | // expected: shared.TagMap{ 71 | // "Owner": []string{"Jake"}, 72 | // "StackName": []string{"!Ref StackName"}, 73 | // }, 74 | // }, 75 | { 76 | name: "literal tags, case insensitive", 77 | properties: map[string]interface{}{ 78 | "Tags": []interface{}{ 79 | map[string]interface{}{"Key": "Owner", "Value": "Jake"}, 80 | map[string]interface{}{"Key": "env", "Value": "Dev"}, 81 | }, 82 | }, 83 | caseInsensitive: true, 84 | expected: shared.TagMap{ 85 | "owner": []string{"Jake"}, 86 | "env": []string{"Dev"}, 87 | }, 88 | }, 89 | { 90 | name: "missing key", 91 | properties: map[string]interface{}{ 92 | "Tags": []interface{}{ 93 | map[string]interface{}{"Value": "Jake"}, 94 | }, 95 | }, 96 | expected: shared.TagMap{}, 97 | }, 98 | { 99 | name: "missing value", 100 | properties: map[string]interface{}{ 101 | "Tags": []interface{}{ 102 | map[string]interface{}{"Key": "OptionalTag"}, 103 | }, 104 | }, 105 | expected: shared.TagMap{ 106 | "OptionalTag": []string{""}, 107 | }, 108 | }, 109 | { 110 | name: "non-string", 111 | properties: map[string]interface{}{ 112 | "Tags": []interface{}{ 113 | map[string]interface{}{"Key": 123, "Value": "Jake"}, 114 | }, 115 | }, 116 | expected: shared.TagMap{}, 117 | }, 118 | } 119 | 120 | for _, tc := range tests { 121 | t.Run(tc.name, func(t *testing.T) { 122 | got, err := extractTagMap(tc.properties, tc.caseInsensitive) 123 | if (err != nil) != tc.expectedErr { 124 | t.Fatalf("extractTagMap() error = %v, expectedErr %v", err, tc.expectedErr) 125 | } 126 | if !tc.expectedErr { 127 | if diff := cmp.Diff(tc.expected, got); diff != "" { 128 | t.Errorf("extractTagMap() mismatch (-expected +got):\n%s", diff) 129 | } 130 | } 131 | }) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /internal/cloudformation/helpers_test.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | import ( 4 | "testing" 5 | 6 | "gopkg.in/yaml.v3" 7 | ) 8 | 9 | // helper to create a yaml.Node for testing 10 | func createYamlNode(t *testing.T, yamlStr string) *yaml.Node { 11 | t.Helper() 12 | var node yaml.Node 13 | err := yaml.Unmarshal([]byte(yamlStr), &node) 14 | if err != nil { 15 | t.Fatalf("Failed to unmarshal test YAML: %v\nYAML:\n%s", err, yamlStr) 16 | } 17 | if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { 18 | return node.Content[0] 19 | } 20 | return &node 21 | } 22 | 23 | func testMapNodes(t *testing.T) { 24 | tests := []struct { 25 | name string 26 | inputYAML string 27 | expectedKeys []string 28 | }{ 29 | { 30 | name: "simple map", 31 | inputYAML: `Key1: Value1\nKey2: 123`, 32 | expectedKeys: []string{"Key1", "Key2"}, 33 | }, 34 | { 35 | name: "nested map", 36 | inputYAML: `Key1: Value1\nKey2:\n Nested1: NestedValue`, 37 | expectedKeys: []string{"Key1", "Key2"}, 38 | }, 39 | { 40 | name: "empty map", 41 | inputYAML: `{}`, 42 | expectedKeys: []string{}, 43 | }, 44 | } 45 | 46 | for _, tc := range tests { 47 | t.Run(tc.name, func(t *testing.T) { 48 | node := createYamlNode(t, tc.inputYAML) 49 | mapped := mapNodes(node) 50 | gotKeys := make([]string, 0, len(mapped)) 51 | for k := range mapped { 52 | gotKeys = append(gotKeys, k) 53 | } 54 | if len(gotKeys) != len(tc.expectedKeys) { 55 | t.Errorf("mapNodes() returned map with %d keys, expected %d. Got keys: %v", len(gotKeys), len(tc.expectedKeys), gotKeys) 56 | return 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func testFindMapNode(t *testing.T) { 63 | yamlContent := ` 64 | RootKey: RootValue 65 | Map1: 66 | NestedKey1: NestedValue1 67 | NestedKey2: 123 68 | Map2: {} 69 | List1: 70 | - itemA 71 | - itemB 72 | ` 73 | rootNode := createYamlNode(t, yamlContent) 74 | 75 | tests := []struct { 76 | name string 77 | node *yaml.Node 78 | key string 79 | expectedNil bool 80 | expectedKind yaml.Kind 81 | expectedValue string 82 | }{ 83 | { 84 | name: "root key to root value", 85 | node: rootNode, 86 | key: "RootKey", 87 | expectedNil: false, 88 | expectedKind: yaml.ScalarNode, 89 | expectedValue: "RootValue", 90 | }, 91 | { 92 | name: "map key to map values", 93 | node: rootNode, 94 | key: "Map1", 95 | expectedNil: false, 96 | expectedKind: yaml.MappingNode, 97 | }, 98 | { 99 | name: "map key to empty map value", 100 | node: rootNode, 101 | key: "Map2", 102 | expectedNil: false, 103 | expectedKind: yaml.MappingNode, 104 | }, 105 | { 106 | name: "list key to list values", 107 | node: rootNode, 108 | key: "List1", 109 | expectedNil: false, 110 | expectedKind: yaml.SequenceNode, 111 | }, 112 | { 113 | name: "missing key", 114 | node: rootNode, 115 | key: "MissingKey", 116 | expectedNil: true, 117 | }, 118 | { 119 | name: "search other node", 120 | node: findMapNode(rootNode, "List1"), 121 | key: "itemA", 122 | expectedNil: true, 123 | }, 124 | } 125 | 126 | for _, tc := range tests { 127 | t.Run(tc.name, func(t *testing.T) { 128 | got := findMapNode(tc.node, tc.key) 129 | isNil := got == nil 130 | if isNil != tc.expectedNil { 131 | t.Fatalf("findMapNode(key=%q) returned nil? %t, expectedNil %t", tc.key, isNil, tc.expectedNil) 132 | } 133 | if !tc.expectedNil { 134 | if got.Kind != tc.expectedKind { 135 | t.Errorf("findMapNode(key=%q) returned node kind %v, expected %v", tc.key, got.Kind, tc.expectedKind) 136 | } 137 | if tc.expectedKind == yaml.ScalarNode && got.Value != tc.expectedValue { 138 | t.Errorf("findMapNode(key=%q) returned scalar value %q, expected %q", tc.key, got.Value, tc.expectedValue) 139 | } 140 | } 141 | }) 142 | } 143 | } 144 | 145 | -------------------------------------------------------------------------------- /internal/cloudformation/process.go: -------------------------------------------------------------------------------- 1 | package cloudformation 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "github.com/jakebark/tag-nag/internal/config" 11 | "github.com/jakebark/tag-nag/internal/shared" 12 | ) 13 | 14 | // ProcessDirectory walks all cfn files in a directory, then returns violations 15 | func ProcessDirectory(dirPath string, requiredTags map[string][]string, caseInsensitive bool, specFilePath string, skip []string) int { 16 | hasFiles, err := scan(dirPath) 17 | if err != nil { 18 | return 0 19 | } 20 | if !hasFiles { 21 | return 0 22 | } 23 | 24 | // log.Println("\nCloudFormation files found") 25 | var totalViolations int 26 | 27 | var taggable map[string]bool 28 | if specFilePath != "" { 29 | var loadSpecErr error 30 | taggable, loadSpecErr = loadTaggableResourcesFromSpec(specFilePath) 31 | if loadSpecErr != nil { 32 | log.Printf("Warning: Could not load or parse --cfn-spec file '%s': %v.", specFilePath, loadSpecErr) 33 | taggable = nil 34 | } else { 35 | // log.Println("Parsing CloudFormation spec file.") 36 | } 37 | } 38 | 39 | walkErr := filepath.Walk(dirPath, func(path string, info os.FileInfo, walkErr error) error { 40 | if walkErr != nil { 41 | log.Printf("Error accessing %q: %v\n", path, walkErr) 42 | return walkErr 43 | } 44 | 45 | for _, skipped := range skip { 46 | if strings.HasPrefix(path, skipped) { 47 | if info.IsDir() { 48 | return filepath.SkipDir 49 | } 50 | return nil 51 | } 52 | } 53 | if info.IsDir() { 54 | dirName := info.Name() 55 | for _, skippedDir := range config.SkippedDirs { 56 | if dirName == skippedDir { 57 | return filepath.SkipDir 58 | } 59 | } 60 | } 61 | 62 | if info.IsDir() { 63 | dirName := info.Name() 64 | for _, skipped := range config.SkippedDirs { 65 | if dirName == skipped { 66 | return filepath.SkipDir 67 | } 68 | } 69 | } 70 | 71 | if !info.IsDir() && (filepath.Ext(path) == ".yaml" || filepath.Ext(path) == ".yml" || filepath.Ext(path) == ".json") { 72 | violations, processErr := processFile(path, requiredTags, caseInsensitive, taggable) 73 | if processErr != nil { 74 | log.Printf("Error processing file %s: %v\n", path, processErr) 75 | return nil // Example: Continue walking 76 | } 77 | totalViolations += len(violations) 78 | } 79 | return nil 80 | }) 81 | if walkErr != nil { 82 | log.Printf("Error scanning directory %s: %v\n", dirPath, walkErr) 83 | } 84 | return totalViolations 85 | } 86 | 87 | // processFile parses files and maps the cfn nodes 88 | func processFile(filePath string, requiredTags shared.TagMap, caseInsensitive bool, taggable map[string]bool) ([]Violation, error) { 89 | data, err := os.ReadFile(filePath) 90 | if err != nil { 91 | log.Printf("Error reading %s: %v\n", filePath, err) 92 | return nil, fmt.Errorf("reading file %s: %w", filePath, err) 93 | } 94 | content := string(data) 95 | lines := strings.Split(content, "\n") 96 | 97 | skipAll := strings.Contains(content, config.TagNagIgnoreAll) 98 | 99 | root, err := parseYAML(filePath) 100 | if err != nil { 101 | return nil, fmt.Errorf("parsing file %s: %w", filePath, err) 102 | } 103 | 104 | // search root node for resources node 105 | resourcesMapping := mapNodes(findMapNode(root, "Resources")) 106 | if resourcesMapping == nil { 107 | log.Printf("No 'Resources' section found in %s\n", filePath) 108 | return []Violation{}, nil 109 | } 110 | 111 | violations := checkResourcesforTags(resourcesMapping, requiredTags, caseInsensitive, lines, skipAll, taggable) 112 | 113 | if len(violations) > 0 { 114 | fmt.Printf("\nViolation(s) in %s\n", filePath) 115 | for _, v := range violations { 116 | if v.skip { 117 | fmt.Printf(" %d: %s \"%s\" skipped\n", v.line, v.resourceType, v.resourceName) 118 | } else { 119 | fmt.Printf(" %d: %s \"%s\" 🏷️ Missing tags: %s\n", v.line, v.resourceType, v.resourceName, strings.Join(v.missingTags, ", ")) 120 | } 121 | } 122 | } 123 | 124 | var filteredViolations []Violation 125 | for _, v := range violations { 126 | if !v.skip { 127 | filteredViolations = append(filteredViolations, v) 128 | } 129 | } 130 | return filteredViolations, nil 131 | } 132 | -------------------------------------------------------------------------------- /internal/terraform/references.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/hashicorp/hcl/v2/hclparse" 11 | "github.com/hashicorp/hcl/v2/hclsyntax" 12 | "github.com/jakebark/tag-nag/internal/config" 13 | "github.com/zclconf/go-cty/cty" 14 | "github.com/zclconf/go-cty/cty/function" 15 | ) 16 | 17 | func buildTagContext(dirPath string) (*TerraformContext, error) { 18 | parsedFiles := make(map[string]*hcl.File) 19 | parser := hclparse.NewParser() 20 | 21 | // first pass, parse files 22 | err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { 23 | if err != nil { 24 | return err 25 | } 26 | if info.IsDir() { 27 | dirName := info.Name() 28 | for _, skipped := range config.SkippedDirs { 29 | if dirName == skipped { 30 | return filepath.SkipDir 31 | } 32 | } 33 | } 34 | if !info.IsDir() && filepath.Ext(path) == ".tf" { 35 | file, diags := parser.ParseHCLFile(path) 36 | if diags.HasErrors() { 37 | log.Printf("Error parsing HCL file %s: %v\n", path, diags) 38 | } 39 | if file != nil { 40 | parsedFiles[path] = file 41 | } 42 | } 43 | return nil 44 | }) 45 | if err != nil { 46 | return nil, fmt.Errorf("error walking directory %s: %w", dirPath, err) 47 | } 48 | 49 | if len(parsedFiles) == 0 { 50 | log.Println("No Terraform files (.tf) found to build context.") 51 | return &TerraformContext{ 52 | EvalContext: &hcl.EvalContext{ 53 | Variables: make(map[string]cty.Value), 54 | Functions: make(map[string]function.Function), 55 | }, 56 | }, nil 57 | } 58 | 59 | // second pass, evaluate vars 60 | tfVars := make(map[string]cty.Value) 61 | for _, file := range parsedFiles { 62 | body, ok := file.Body.(*hclsyntax.Body) 63 | if !ok { 64 | continue 65 | } 66 | 67 | for _, block := range body.Blocks { 68 | if block.Type == "variable" && len(block.Labels) > 0 { 69 | varName := block.Labels[0] 70 | if defaultAttr, exists := block.Body.Attributes["default"]; exists { 71 | val, diags := defaultAttr.Expr.Value(nil) 72 | if diags.HasErrors() { 73 | log.Printf("Error evaluating default for variable %q: %v", varName, diags) 74 | val = cty.NullVal(cty.DynamicPseudoType) 75 | } 76 | tfVars[varName] = val 77 | } else { 78 | tfVars[varName] = cty.NullVal(cty.DynamicPseudoType) 79 | } 80 | } 81 | } 82 | } 83 | 84 | // 3rd pass, evaluate locals 85 | tfLocals := make(map[string]cty.Value) 86 | localsDefs := make(map[string]hcl.Expression) 87 | 88 | for _, file := range parsedFiles { 89 | body, ok := file.Body.(*hclsyntax.Body) 90 | if !ok { 91 | continue 92 | } 93 | for _, block := range body.Blocks { 94 | if block.Type == "locals" { 95 | for name, attr := range block.Body.Attributes { 96 | localsDefs[name] = attr.Expr 97 | } 98 | } 99 | } 100 | } 101 | 102 | evalCtxForLocals := &hcl.EvalContext{ 103 | Variables: map[string]cty.Value{"var": cty.ObjectVal(tfVars)}, 104 | Functions: config.StdlibFuncs, 105 | } 106 | evalCtxForLocals.Variables["local"] = cty.NullVal(cty.DynamicPseudoType) // Placeholder for local 107 | 108 | const maxLocalPasses = 10 109 | evaluatedCount := 0 110 | for pass := 0; pass < maxLocalPasses && evaluatedCount < len(localsDefs); pass++ { 111 | madeProgress := false 112 | 113 | evalCtxForLocals.Variables["local"] = cty.ObjectVal(tfLocals) 114 | 115 | for name, expr := range localsDefs { 116 | if _, exists := tfLocals[name]; exists { 117 | continue 118 | } 119 | 120 | val, diags := expr.Value(evalCtxForLocals) 121 | if !diags.HasErrors() { 122 | tfLocals[name] = val 123 | evaluatedCount++ 124 | madeProgress = true 125 | } 126 | 127 | } 128 | if !madeProgress && evaluatedCount < len(localsDefs) { 129 | log.Printf("Warning: Could not resolve all locals dependencies after %d passes.", pass+1) 130 | 131 | break 132 | } 133 | } 134 | 135 | finalCtx := &hcl.EvalContext{ 136 | Variables: map[string]cty.Value{ 137 | "var": cty.ObjectVal(tfVars), 138 | "local": cty.ObjectVal(tfLocals), 139 | }, 140 | Functions: config.StdlibFuncs, 141 | } 142 | 143 | return &TerraformContext{EvalContext: finalCtx}, nil 144 | } 145 | -------------------------------------------------------------------------------- /internal/inputs/inputs_test.go: -------------------------------------------------------------------------------- 1 | package inputs 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/jakebark/tag-nag/internal/shared" 8 | ) 9 | 10 | func TestParseTags(t *testing.T) { 11 | testCases := []struct { 12 | name string 13 | input string 14 | expected shared.TagMap 15 | expectedError bool 16 | }{ 17 | { 18 | name: "key", 19 | input: "Owner", 20 | expected: shared.TagMap{ 21 | "Owner": {}, 22 | }, 23 | expectedError: false, 24 | }, 25 | { 26 | name: "multiple keys", 27 | input: "Owner, Environment , Project", 28 | expected: shared.TagMap{ 29 | "Owner": {}, 30 | "Environment": {}, 31 | "Project": {}, 32 | }, 33 | expectedError: false, 34 | }, 35 | { 36 | name: "mixed keys and values", 37 | input: "Owner[jake], Environment[Dev,Prod], CostCenter", 38 | expected: shared.TagMap{ 39 | "Owner": {"jake"}, 40 | "Environment": {"Dev", "Prod"}, 41 | "CostCenter": {}, 42 | }, 43 | expectedError: false, 44 | }, 45 | { 46 | name: "empty", 47 | input: "", 48 | expected: shared.TagMap{}, 49 | expectedError: false, 50 | }, 51 | { 52 | name: "whitespace", 53 | input: " , ", 54 | expected: shared.TagMap{}, 55 | expectedError: false, 56 | }, 57 | { 58 | name: "mixed keys and values, with whitespace", 59 | input: " Owner , Environment[Dev, Prod] ", 60 | expected: shared.TagMap{ 61 | "Owner": {}, 62 | "Environment": {"Dev", "Prod"}, 63 | }, 64 | expectedError: false, 65 | }, 66 | { 67 | name: "leading comma", 68 | input: ",Owner", 69 | expected: shared.TagMap{ 70 | "Owner": {}, 71 | }, 72 | expectedError: false, 73 | }, 74 | { 75 | name: "missing value", 76 | input: "Env[]", 77 | expected: shared.TagMap{ 78 | "Env": {}, // No values extracted 79 | }, 80 | expectedError: false, 81 | }, 82 | { 83 | name: "missing value, other values present", 84 | input: "Env[Dev,,Prod]", 85 | expected: shared.TagMap{ 86 | "Env": {"Dev", "Prod"}, 87 | }, 88 | expectedError: false, 89 | }, 90 | { 91 | name: "whitespace preserved", 92 | input: "Owner[it belongs to me]", 93 | expected: shared.TagMap{ 94 | "Owner": {"it belongs to me"}, 95 | }, 96 | expectedError: false, 97 | }, 98 | { 99 | name: "unclosed bracket", 100 | input: "invalid[value", 101 | expected: nil, 102 | expectedError: true, 103 | }, 104 | { 105 | name: "no key", 106 | input: "[value]", 107 | expected: nil, 108 | expectedError: true, 109 | }, 110 | { 111 | name: "stray bracket", 112 | input: "stray]", 113 | expected: nil, 114 | expectedError: true, 115 | }, 116 | } 117 | 118 | for _, tc := range testCases { 119 | t.Run(tc.name, func(t *testing.T) { 120 | actual, err := parseTags(tc.input) 121 | if tc.expectedError { 122 | if err == nil { 123 | t.Errorf("parseTags(%q) expected an error, but got nil", tc.input) 124 | } 125 | return 126 | } 127 | if err != nil { 128 | t.Errorf("parseTags(%q) expected no error, but got: %v", tc.input, err) 129 | } 130 | if !reflect.DeepEqual(actual, tc.expected) { 131 | t.Errorf("parseTags(%q) = %v; want %v", tc.input, actual, tc.expected) 132 | } 133 | }) 134 | } 135 | } 136 | 137 | func TestSplitTags(t *testing.T) { 138 | testCases := []struct { 139 | name string 140 | input string 141 | expected []string 142 | }{ 143 | {"empty", "", []string{""}}, 144 | {"key", "Owner", []string{"Owner"}}, 145 | {"multiple keys", "Owner, Env , Project", []string{"Owner", "Env", "Project"}}, 146 | {"value", "Owner[Jake]", []string{"Owner[Jake]"}}, 147 | {"multiple values", "Env[Dev,Prod]", []string{"Env[Dev,Prod]"}}, 148 | {"mixed keys and values", "Owner[Jake], Env[Dev,Prod], CostCenter", []string{"Owner[Jake]", "Env[Dev,Prod]", "CostCenter"}}, 149 | {"trailing comma", "Owner,Env,", []string{"Owner", "Env", ""}}, 150 | {"leading comma", ",Owner,Env", []string{"", "Owner", "Env"}}, 151 | } 152 | 153 | for _, tc := range testCases { 154 | t.Run(tc.name, func(t *testing.T) { 155 | actual := splitTags(tc.input) 156 | if !reflect.DeepEqual(actual, tc.expected) { 157 | t.Errorf("splitTags(%q) = %v; want %v", tc.input, actual, tc.expected) 158 | } 159 | }) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /internal/terraform/helpers.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os/exec" 8 | "strings" 9 | 10 | "github.com/hashicorp/hcl/v2" 11 | "github.com/hashicorp/hcl/v2/hclsyntax" 12 | "github.com/jakebark/tag-nag/internal/config" 13 | "github.com/jakebark/tag-nag/internal/shared" 14 | "github.com/zclconf/go-cty/cty" 15 | ctyjson "github.com/zclconf/go-cty/cty/json" 16 | ) 17 | 18 | // traversalToString converts a hcl hierachical/traversal string to a literal string 19 | func traversalToString(expr hcl.Expression, caseInsensitive bool) string { 20 | if ste, ok := expr.(*hclsyntax.ScopeTraversalExpr); ok { 21 | tokens := []string{} 22 | for _, step := range ste.Traversal { 23 | switch t := step.(type) { 24 | case hcl.TraverseRoot: 25 | tokens = append(tokens, t.Name) 26 | case hcl.TraverseAttr: 27 | tokens = append(tokens, t.Name) 28 | } 29 | } 30 | result := strings.Join(tokens, ".") 31 | if caseInsensitive { 32 | result = strings.ToLower(result) 33 | } 34 | return result 35 | } 36 | // fallback - attempt to evaluate the expression as a literal value 37 | if v, diags := expr.Value(nil); !diags.HasErrors() { 38 | if v.Type().Equals(cty.String) { 39 | s := v.AsString() 40 | if caseInsensitive { 41 | s = strings.ToLower(s) 42 | } 43 | return s 44 | } else { 45 | return fmt.Sprintf("%v", v) 46 | } 47 | } 48 | return "" 49 | } 50 | 51 | // mergeTags combines multiple tag maps 52 | func mergeTags(tagMaps ...shared.TagMap) shared.TagMap { 53 | merged := make(shared.TagMap) 54 | for _, m := range tagMaps { 55 | for k, v := range m { 56 | merged[k] = v 57 | } 58 | } 59 | return merged 60 | } 61 | 62 | // SkipResource determines if a resource block should be skipped 63 | func SkipResource(block *hclsyntax.Block, lines []string) bool { 64 | index := block.DefRange().Start.Line 65 | if index < len(lines) { 66 | if strings.Contains(lines[index], config.TagNagIgnore) { 67 | return true 68 | } 69 | } 70 | return false 71 | } 72 | 73 | func convertCtyValueToString(val cty.Value) (string, error) { 74 | if !val.IsKnown() { 75 | return "", fmt.Errorf("value is unknown") 76 | } 77 | if val.IsNull() { 78 | return "", nil 79 | } 80 | 81 | ty := val.Type() 82 | switch { 83 | case ty == cty.String: 84 | return val.AsString(), nil 85 | case ty == cty.Number: 86 | bf := val.AsBigFloat() 87 | return bf.Text('f', -1), nil 88 | case ty == cty.Bool: 89 | return fmt.Sprintf("%t", val.True()), nil 90 | case ty.IsListType() || ty.IsTupleType() || ty.IsSetType() || ty.IsMapType() || ty.IsObjectType(): 91 | 92 | simpleJSON, err := ctyjson.SimpleJSONValue{Value: val}.MarshalJSON() 93 | if err != nil { 94 | return "", fmt.Errorf("failed to marshal complex type to json: %w", err) 95 | } 96 | strJSON := string(simpleJSON) 97 | if len(strJSON) >= 2 && strJSON[0] == '"' && strJSON[len(strJSON)-1] == '"' { 98 | var unquotedStr string 99 | if err := json.Unmarshal(simpleJSON, &unquotedStr); err == nil { 100 | return unquotedStr, nil 101 | } 102 | } 103 | return strJSON, nil 104 | default: 105 | return fmt.Sprintf("%v", val), nil // Best effort 106 | } 107 | } 108 | 109 | // loadTaggableResources calls the Terraform JSON schema and returns a set of all resources that are taggable 110 | func loadTaggableResources(providerAddr string) map[string]bool { 111 | out, err := exec.Command( 112 | "terraform", "providers", "schema", "-json", 113 | ).Output() 114 | if err != nil { 115 | // log.Printf("Failed to load AWS terraform provider schema: %v", err) 116 | return nil 117 | } 118 | 119 | // unmarshall what we need 120 | var s struct { 121 | ProviderSchemas map[string]struct { 122 | ResourceSchemas map[string]struct { 123 | Block struct { 124 | Attributes map[string]json.RawMessage `json:"attributes"` 125 | } `json:"block"` 126 | } `json:"resource_schemas"` 127 | } `json:"provider_schemas"` 128 | } 129 | if err := json.Unmarshal(out, &s); err != nil { 130 | log.Fatalf("failed to parse schema JSON: %v", err) 131 | return nil 132 | } 133 | 134 | taggable := make(map[string]bool) 135 | if ps, ok := s.ProviderSchemas[providerAddr]; ok { 136 | for resType, schema := range ps.ResourceSchemas { 137 | if _, has := schema.Block.Attributes["tags"]; has { 138 | taggable[resType] = true 139 | } else { // 140 | taggable[resType] = false 141 | } 142 | } 143 | } else { 144 | return nil 145 | } 146 | 147 | return taggable 148 | } 149 | -------------------------------------------------------------------------------- /internal/terraform/default_tags.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sort" 7 | "strings" 8 | 9 | "github.com/hashicorp/hcl/v2/hclparse" 10 | "github.com/hashicorp/hcl/v2/hclsyntax" 11 | "github.com/jakebark/tag-nag/internal/shared" 12 | "github.com/zclconf/go-cty/cty" 13 | ) 14 | 15 | // processDefaultTags identifies the default tags 16 | func processDefaultTags(tfFiles []tfFile, tfCtx *TerraformContext, caseInsensitive bool) DefaultTags { 17 | defaultTags := DefaultTags{ 18 | LiteralTags: make(map[string]shared.TagMap), 19 | } 20 | 21 | parser := hclparse.NewParser() 22 | 23 | for _, tf := range tfFiles { 24 | file, diags := parser.ParseHCLFile(tf.path) 25 | if diags.HasErrors() || file == nil { 26 | log.Printf("Error parsing %s during default tag scan: %v\n", tf.path, diags) 27 | continue 28 | } 29 | 30 | syntaxBody, ok := file.Body.(*hclsyntax.Body) 31 | if !ok { 32 | log.Printf("Failed to get syntax body for %s\n", tf.path) 33 | continue 34 | } 35 | 36 | processProviders(syntaxBody, &defaultTags, tfCtx, caseInsensitive) 37 | } 38 | 39 | return defaultTags 40 | } 41 | 42 | // processProviders extracts any default_tags from providers 43 | func processProviders(body *hclsyntax.Body, defaultTags *DefaultTags, tfCtx *TerraformContext, caseInsensitive bool) { 44 | for _, block := range body.Blocks { 45 | if block.Type == "provider" && len(block.Labels) > 0 { 46 | providerID := getProviderID(block, caseInsensitive) // handle ID 47 | tags := getDefaultTags(block, tfCtx, caseInsensitive) // handle tags 48 | 49 | if len(tags) > 0 { 50 | var keys []string 51 | for key := range tags { 52 | keys = append(keys, key) // remove bool element of tag map 53 | } 54 | sort.Strings(keys) 55 | fmt.Printf("Found Terraform default tags for provider %s: [%v]\n", providerID, strings.Join(keys, ", ")) 56 | defaultTags.LiteralTags[providerID] = tags 57 | 58 | } 59 | } 60 | } 61 | } 62 | 63 | // getProviderID returns the provider identifier (aws or alias) 64 | func getProviderID(block *hclsyntax.Block, caseInsensitive bool) string { 65 | providerName := block.Labels[0] 66 | var alias string 67 | 68 | // check for alias presence 69 | if attr, ok := block.Body.Attributes["alias"]; ok { 70 | val, diags := attr.Expr.Value(nil) 71 | if !diags.HasErrors() { 72 | alias = val.AsString() 73 | } 74 | } 75 | return normalizeProviderID(providerName, alias, caseInsensitive) 76 | } 77 | 78 | // normalize ProviderID combines the provider name and alias ("aws.west"), aligning with resource provider naming 79 | func normalizeProviderID(providerName, alias string, caseInsensitive bool) string { 80 | providerID := providerName 81 | if alias != "" { 82 | providerID += "." + alias 83 | } 84 | 85 | if caseInsensitive { 86 | providerID = strings.ToLower(providerID) 87 | } 88 | 89 | return providerID 90 | } 91 | 92 | // getDefaultTags returns the default_tags on a provider block. 93 | func getDefaultTags(block *hclsyntax.Block, tfCtx *TerraformContext, caseInsensitive bool) shared.TagMap { // Add tfCtx param 94 | for _, subBlock := range block.Body.Blocks { 95 | if subBlock.Type == "default_tags" { 96 | if tagsAttr, exists := subBlock.Body.Attributes["tags"]; exists { 97 | tagsVal, diags := tagsAttr.Expr.Value(tfCtx.EvalContext) 98 | if diags.HasErrors() { 99 | log.Printf("Error evaluating default_tags expression for provider %v: %v", block.Labels, diags) 100 | return nil 101 | } 102 | 103 | if !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() { 104 | log.Printf("Warning: Evaluated default_tags for provider %v is not an object/map type, but %s. Skipping.", block.Labels, tagsVal.Type().FriendlyName()) 105 | return nil 106 | } 107 | if tagsVal.IsNull() { 108 | log.Printf("Warning: Evaluated default_tags for provider %v is null. Skipping.", block.Labels) 109 | return nil 110 | } 111 | 112 | evalTags := make(shared.TagMap) 113 | for key, val := range tagsVal.AsValueMap() { 114 | var valStr string 115 | if val.IsNull() { 116 | valStr = "" 117 | } else if val.Type() == cty.String { 118 | valStr = val.AsString() 119 | } else { 120 | strResult, err := convertCtyValueToString(val) 121 | if err != nil { 122 | log.Printf("Warning: Could not convert default tag value for key %q to string: %v. Using empty string.", key, err) 123 | valStr = "" 124 | } else { 125 | valStr = strResult 126 | } 127 | } 128 | 129 | effectiveKey := key 130 | if caseInsensitive { 131 | effectiveKey = strings.ToLower(key) 132 | } 133 | evalTags[effectiveKey] = []string{valStr} 134 | } 135 | return evalTags 136 | } 137 | } 138 | } 139 | return nil // No default_tags block found 140 | } 141 | -------------------------------------------------------------------------------- /internal/terraform/resources.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/hashicorp/hcl/v2/hclsyntax" 8 | "github.com/jakebark/tag-nag/internal/shared" 9 | "github.com/zclconf/go-cty/cty" 10 | ) 11 | 12 | // checkResourcesForTags inspects resource blocks and returns violations 13 | func checkResourcesForTags(body *hclsyntax.Body, requiredTags shared.TagMap, defaultTags *DefaultTags, tfCtx *TerraformContext, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool) []Violation { 14 | var violations []Violation 15 | 16 | for _, block := range body.Blocks { 17 | if block.Type != "resource" || len(block.Labels) < 2 { // skip anything without 2 labels eg "aws_s3_bucket" and "this" 18 | continue 19 | } 20 | 21 | resourceType := block.Labels[0] // aws_s3_bucket 22 | resourceName := block.Labels[1] // this 23 | 24 | if !strings.HasPrefix(resourceType, "aws_") { 25 | continue 26 | } 27 | 28 | isTaggable := true // assume resource is taggable, by default 29 | if taggable != nil { 30 | var found bool 31 | isTaggable, found = taggable[resourceType] 32 | if !found { 33 | isTaggable = true // if not found, assume resource is taggable 34 | // isTaggable = false 35 | // log.Printf("Warning: Resource type %s not found in provider schema. Assuming not taggable.", resourceType) //todo 36 | } 37 | } else { 38 | } 39 | 40 | if !isTaggable { 41 | // log.Printf("Skipping non-taggable resource type: %s", resourceType) 42 | continue 43 | } 44 | 45 | providerID := getResourceProvider(block, caseInsensitive) 46 | providerEvalTags := defaultTags.LiteralTags[providerID] 47 | if providerEvalTags == nil { 48 | providerEvalTags = make(shared.TagMap) 49 | } 50 | 51 | resourceEvalTags := findTags(block, tfCtx, caseInsensitive) 52 | effectiveTags := mergeTags(providerEvalTags, resourceEvalTags) 53 | 54 | missingTags := shared.FilterMissingTags(requiredTags, effectiveTags, caseInsensitive) 55 | if len(missingTags) > 0 { 56 | violation := Violation{ 57 | resourceType: resourceType, 58 | resourceName: resourceName, 59 | line: block.DefRange().Start.Line, 60 | missingTags: missingTags, 61 | } 62 | if skipAll || SkipResource(block, fileLines) { 63 | violation.skip = true 64 | } 65 | violations = append(violations, violation) 66 | } 67 | } 68 | return violations 69 | } 70 | 71 | // getResourceProvider determines the provider for a resource block 72 | func getResourceProvider(block *hclsyntax.Block, caseInsensitive bool) string { 73 | if attr, ok := block.Body.Attributes["provider"]; ok { 74 | 75 | // provider is a literal string ("aws") 76 | val, diags := attr.Expr.Value(nil) 77 | if !diags.HasErrors() { 78 | s := val.AsString() 79 | if caseInsensitive { 80 | s = strings.ToLower(s) 81 | } 82 | return s 83 | } 84 | // provider is not a literal string ("aws.west") 85 | s := traversalToString(attr.Expr, caseInsensitive) 86 | if s != "" { 87 | return s 88 | } 89 | } 90 | 91 | // no explicit provider, return default provider 92 | defaultProvider := "aws" 93 | if caseInsensitive { 94 | defaultProvider = strings.ToLower(defaultProvider) 95 | } 96 | return defaultProvider 97 | } 98 | 99 | // findTags returns tag map from a resource block (with extractTags), if it has tags 100 | func findTags(block *hclsyntax.Block, tfCtx *TerraformContext, caseInsensitive bool) shared.TagMap { 101 | evalTags := make(shared.TagMap) 102 | if attr, exists := block.Body.Attributes["tags"]; exists { 103 | 104 | childCtx := tfCtx.EvalContext.NewChild() 105 | if childCtx.Variables == nil { 106 | childCtx.Variables = make(map[string]cty.Value) 107 | } 108 | childCtx.Variables["each"] = cty.ObjectVal(map[string]cty.Value{ 109 | "key": cty.StringVal(""), 110 | "value": cty.StringVal(""), 111 | }) 112 | tagsVal, diags := attr.Expr.Value(childCtx) 113 | 114 | if diags.HasErrors() { 115 | log.Printf("Error evaluating tags for resource %s.%s: %v", block.Labels[0], block.Labels[1], diags) 116 | return evalTags 117 | } 118 | 119 | if !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() { 120 | return evalTags 121 | } 122 | if tagsVal.IsNull() { 123 | return evalTags 124 | } 125 | 126 | for key, val := range tagsVal.AsValueMap() { 127 | var valStr string 128 | if val.IsNull() { 129 | valStr = "" 130 | } else if val.Type() == cty.String { 131 | valStr = val.AsString() 132 | } else { 133 | strResult, err := convertCtyValueToString(val) 134 | if err != nil { 135 | valStr = "" 136 | } else { 137 | valStr = strResult 138 | } 139 | } 140 | 141 | effectiveKey := key 142 | if caseInsensitive { 143 | effectiveKey = strings.ToLower(key) 144 | } 145 | evalTags[effectiveKey] = []string{valStr} 146 | } 147 | } 148 | return evalTags 149 | } 150 | -------------------------------------------------------------------------------- /internal/terraform/process.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/hashicorp/hcl/v2" 10 | "github.com/hashicorp/hcl/v2/hclparse" 11 | "github.com/hashicorp/hcl/v2/hclsyntax" 12 | "github.com/jakebark/tag-nag/internal/config" 13 | "github.com/jakebark/tag-nag/internal/shared" 14 | "github.com/zclconf/go-cty/cty" 15 | "github.com/zclconf/go-cty/cty/function" 16 | ) 17 | 18 | type tfFile struct { 19 | path string 20 | info os.FileInfo 21 | } 22 | 23 | // ProcessDirectory walks all terraform files in directory 24 | func ProcessDirectory(dirPath string, requiredTags map[string][]string, caseInsensitive bool, skip []string) int { 25 | hasFiles, err := scan(dirPath) 26 | if err != nil { 27 | return 0 28 | } 29 | if !hasFiles { 30 | return 0 31 | } 32 | 33 | // log.Println("Terraform files found\n") 34 | var totalViolations int 35 | 36 | taggable := loadTaggableResources("registry.terraform.io/hashicorp/aws") 37 | if taggable == nil { 38 | // log.Printf("Warning: Failed to load Terraform AWS Provider\nRun 'terraform init' to fix\n") 39 | // log.Printf("Continuing with limited features ... \n ") 40 | } 41 | 42 | tfCtx, err := buildTagContext(dirPath) 43 | if err != nil { 44 | tfCtx = &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value), Functions: make(map[string]function.Function)}} 45 | } 46 | 47 | // single directory walk 48 | tfFiles, err := collectFiles(dirPath, skip) 49 | if err != nil { 50 | log.Printf("Error scanning directory %q: %v\n", dirPath, err) 51 | return 0 52 | } 53 | 54 | if len(tfFiles) == 0 { 55 | return 0 56 | } 57 | 58 | // extract default tags from all files 59 | defaultTags := processDefaultTags(tfFiles, tfCtx, caseInsensitive) 60 | 61 | // process resources for tag violations 62 | for _, tf := range tfFiles { 63 | violations := processFile(tf.path, requiredTags, &defaultTags, tfCtx, caseInsensitive, taggable) 64 | totalViolations += len(violations) 65 | } 66 | 67 | return totalViolations 68 | } 69 | 70 | // collectFiles identifies all elligible terraform files 71 | func collectFiles(dirPath string, skip []string) ([]tfFile, error) { 72 | var tfFiles []tfFile 73 | 74 | err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error { 75 | if err != nil { 76 | return err 77 | } 78 | 79 | if skipDirectories(path, info, skip) { 80 | if info.IsDir() { 81 | return filepath.SkipDir 82 | } 83 | return nil 84 | } 85 | 86 | if !info.IsDir() && filepath.Ext(path) == ".tf" { 87 | tfFiles = append(tfFiles, tfFile{path: path, info: info}) 88 | } 89 | return nil 90 | }) 91 | 92 | return tfFiles, err 93 | } 94 | 95 | // skipDir identifies directories to ignore 96 | func skipDirectories(path string, info os.FileInfo, skip []string) bool { 97 | // user-defined skip paths 98 | for _, skipped := range skip { 99 | if strings.HasPrefix(path, skipped) { 100 | return true 101 | } 102 | } 103 | 104 | // default skipped directories eg .git 105 | if info.IsDir() { 106 | dirName := info.Name() 107 | for _, skippedDir := range config.SkippedDirs { 108 | if dirName == skippedDir { 109 | return true 110 | } 111 | } 112 | } 113 | 114 | return false 115 | } 116 | 117 | // processFile parses files looking for resources 118 | func processFile(filePath string, requiredTags shared.TagMap, defaultTags *DefaultTags, tfCtx *TerraformContext, caseInsensitive bool, taggable map[string]bool) []Violation { 119 | data, err := os.ReadFile(filePath) 120 | if err != nil { 121 | log.Printf("Error reading %s: %v\n", filePath, err) 122 | return nil 123 | } 124 | content := string(data) 125 | lines := strings.Split(content, "\n") 126 | 127 | skipAll := strings.Contains(content, config.TagNagIgnoreAll) 128 | 129 | parser := hclparse.NewParser() 130 | file, diagnostics := parser.ParseHCLFile(filePath) 131 | 132 | if diagnostics.HasErrors() { 133 | log.Printf("Error parsing %s: %v\n", filePath, diagnostics) 134 | return nil 135 | } 136 | 137 | syntaxBody, ok := file.Body.(*hclsyntax.Body) 138 | if !ok { 139 | log.Printf("Parsing failed for %s\n", filePath) 140 | return nil 141 | } 142 | 143 | violations := checkResourcesForTags(syntaxBody, requiredTags, defaultTags, tfCtx, caseInsensitive, lines, skipAll, taggable) 144 | 145 | if len(violations) > 0 { 146 | log.Printf("\nViolation(s) in %s\n", filePath) 147 | for _, v := range violations { 148 | if v.skip { 149 | log.Printf(" %d: %s \"%s\" skipped\n", v.line, v.resourceType, v.resourceName) 150 | } else { 151 | log.Printf(" %d: %s \"%s\" 🏷️ Missing tags: %s\n", v.line, v.resourceType, v.resourceName, strings.Join(v.missingTags, ", ")) 152 | } 153 | } 154 | } 155 | 156 | var filteredViolations []Violation 157 | for _, v := range violations { 158 | if !v.skip { 159 | filteredViolations = append(filteredViolations, v) 160 | } 161 | } 162 | return filteredViolations 163 | } 164 | -------------------------------------------------------------------------------- /internal/inputs/inputs.go: -------------------------------------------------------------------------------- 1 | package inputs 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | 8 | "github.com/jakebark/tag-nag/internal/shared" 9 | "github.com/spf13/pflag" 10 | ) 11 | 12 | type UserInput struct { 13 | Directory string 14 | RequiredTags shared.TagMap 15 | CaseInsensitive bool 16 | DryRun bool 17 | CfnSpecPath string 18 | Skip []string 19 | } 20 | 21 | // ParseFlags returns pased CLI flags and arguments 22 | func ParseFlags() UserInput { 23 | var caseInsensitive bool 24 | var dryRun bool 25 | var tags string 26 | var cfnSpecPath string 27 | var skip string 28 | 29 | pflag.BoolVarP(&caseInsensitive, "case-insensitive", "c", false, "Make tag checks non-case-sensitive") 30 | pflag.BoolVarP(&dryRun, "dry-run", "d", false, "Dry run tag:nag without triggering exit(1) code") 31 | pflag.StringVar(&tags, "tags", "", "Comma-separated list of required tag keys (e.g., 'Owner,Environment[Dev,Prod]')") 32 | pflag.StringVar(&cfnSpecPath, "cfn-spec", "", "Optional path to CloudFormationResourceSpecification.json)") 33 | pflag.StringVarP(&skip, "skip", "s", "", "Comma-separated list of files or directories to skip") 34 | pflag.Parse() 35 | 36 | if pflag.NArg() < 1 { 37 | log.Fatal("Error: specify a directory or file to scan") 38 | } 39 | 40 | // try config file if no tags provided 41 | if tags == "" { 42 | configFile, err := FindAndLoadConfigFile() 43 | if err != nil { 44 | log.Fatalf("Error loading config: %v", err) 45 | } 46 | if configFile != nil { 47 | return UserInput{ 48 | Directory: pflag.Arg(0), 49 | RequiredTags: configFile.convertToTagMap(), 50 | CaseInsensitive: configFile.Settings.CaseInsensitive, 51 | DryRun: configFile.Settings.DryRun, 52 | CfnSpecPath: configFile.Settings.CfnSpec, 53 | Skip: configFile.Skip, 54 | } 55 | } 56 | log.Fatal("Error: specify required tags using --tags or create a .tag-nag.yml config file") 57 | } 58 | 59 | parsedTags, err := parseTags(tags) 60 | if err != nil { 61 | log.Fatalf("Error parsing tags: %v", err) 62 | } 63 | 64 | var skipPaths []string 65 | if skip != "" { 66 | skipPaths = strings.Split(skip, ",") 67 | for i := range skipPaths { 68 | skipPaths[i] = strings.TrimSpace(skipPaths[i]) 69 | } 70 | } 71 | 72 | return UserInput{ 73 | Directory: pflag.Arg(0), 74 | RequiredTags: parsedTags, 75 | CaseInsensitive: caseInsensitive, 76 | DryRun: dryRun, 77 | CfnSpecPath: cfnSpecPath, 78 | Skip: skipPaths, 79 | } 80 | } 81 | 82 | // parses tag input components 83 | func parseTags(input string) (shared.TagMap, error) { 84 | tagMap := make(shared.TagMap) 85 | pairs := splitTags(input) 86 | for _, pair := range pairs { 87 | trimmed := strings.TrimSpace(pair) 88 | if trimmed == "" { 89 | continue 90 | } 91 | 92 | key, values, err := parseTag(trimmed) 93 | if err != nil { 94 | return nil, fmt.Errorf("failed to parse tag component '%s': %w", trimmed, err) 95 | } 96 | tagMap[key] = values 97 | } 98 | return tagMap, nil 99 | } 100 | 101 | // parses tag keys and values 102 | func parseTag(tagComponent string) (key string, values []string, err error) { 103 | trimmed := strings.TrimSpace(tagComponent) 104 | if trimmed == "" { 105 | return "", nil, fmt.Errorf("empty tag component") 106 | } 107 | 108 | // key and value 109 | openBracketIdx := strings.Index(trimmed, "[") 110 | if openBracketIdx != -1 { 111 | if !strings.HasSuffix(trimmed, "]") { 112 | return "", nil, fmt.Errorf("invalid tag format: %s. Expected closing ']'", trimmed) 113 | } 114 | 115 | key = strings.TrimSpace(trimmed[:openBracketIdx]) 116 | if key == "" { 117 | return "", nil, fmt.Errorf("empty key in bracket format: %s", trimmed) 118 | } 119 | 120 | valuesStr := trimmed[openBracketIdx+1 : len(trimmed)-1] 121 | if valuesStr == "" { 122 | return key, []string{}, nil 123 | } 124 | 125 | valParts := strings.Split(valuesStr, ",") 126 | for _, v := range valParts { 127 | trimmedVal := strings.TrimSpace(v) 128 | if trimmedVal != "" { 129 | values = append(values, trimmedVal) 130 | } 131 | } 132 | return key, values, nil 133 | } 134 | 135 | // key only 136 | if strings.Contains(trimmed, "[") || strings.Contains(trimmed, "]") { 137 | return "", nil, fmt.Errorf("invalid tag format: %s. Contains '[' or ']' without matching pair or value definition", trimmed) 138 | } 139 | return trimmed, []string{}, nil 140 | } 141 | 142 | // splitTags splits the input string on commas outside of brackets 143 | // to fix the [a,b,c] issue 144 | func splitTags(input string) []string { 145 | var parts []string 146 | start := 0 147 | depth := 0 148 | for i, r := range input { 149 | switch r { 150 | case '[': 151 | depth++ 152 | case ']': 153 | if depth > 0 { 154 | depth-- 155 | } 156 | case ',': 157 | if depth == 0 { 158 | parts = append(parts, strings.TrimSpace(input[start:i])) 159 | start = i + 1 160 | } 161 | } 162 | } 163 | parts = append(parts, strings.TrimSpace(input[start:])) 164 | return parts 165 | } 166 | -------------------------------------------------------------------------------- /internal/terraform/helpers_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/hashicorp/hcl/v2" 8 | "github.com/hashicorp/hcl/v2/hclsyntax" 9 | "github.com/jakebark/tag-nag/internal/shared" 10 | "github.com/zclconf/go-cty/cty" 11 | ) 12 | 13 | func TestTraversalToString(t *testing.T) { 14 | testCases := []struct { 15 | name string 16 | hclInput string 17 | caseInsensitive bool 18 | expected string 19 | }{ 20 | { 21 | name: "literal", 22 | hclInput: "Owner", 23 | caseInsensitive: false, 24 | expected: "Owner", 25 | }, 26 | { 27 | name: "literal, case insensitive", 28 | hclInput: "Owner", 29 | caseInsensitive: true, 30 | expected: "owner", 31 | }, 32 | { 33 | name: "traversal", 34 | hclInput: "local.network.subnets[0].id", 35 | caseInsensitive: false, 36 | expected: "local.network.subnets.id", 37 | }, 38 | } 39 | 40 | for _, tc := range testCases { 41 | t.Run(tc.name, func(t *testing.T) { 42 | // Parse the HCL expression string 43 | expr, diags := hclsyntax.ParseExpression([]byte(tc.hclInput), tc.name+".tf", hcl.Pos{Line: 1, Column: 1}) 44 | if diags.HasErrors() { 45 | t.Fatalf("Failed to parse expression %q: %v", tc.hclInput, diags) 46 | } 47 | 48 | actual := traversalToString(expr, tc.caseInsensitive) 49 | if actual != tc.expected { 50 | t.Errorf("traversalToString(%q, %v) = %q; want %q", tc.hclInput, tc.caseInsensitive, actual, tc.expected) 51 | } 52 | }) 53 | } 54 | } 55 | 56 | func TestMergeTags(t *testing.T) { 57 | testCases := []struct { 58 | name string 59 | inputs []shared.TagMap 60 | expected shared.TagMap 61 | }{ 62 | { 63 | name: "empty", 64 | inputs: []shared.TagMap{}, 65 | expected: shared.TagMap{}, 66 | }, 67 | { 68 | name: "key", 69 | inputs: []shared.TagMap{ 70 | {"Environment": {}}, 71 | }, 72 | expected: shared.TagMap{"Environment": {}}, 73 | }, 74 | { 75 | name: "key and value", 76 | inputs: []shared.TagMap{{"Environment": {"Dev"}}}, 77 | expected: shared.TagMap{"Environment": {"Dev"}}, 78 | }, 79 | { 80 | name: "multiple keys and values", 81 | inputs: []shared.TagMap{ 82 | {"Environment": {"Dev"}}, 83 | {"Owner": {"Prod"}}, 84 | }, 85 | expected: shared.TagMap{"Environment": {"Dev"}, "Owner": {"Prod"}}, 86 | }, 87 | { 88 | name: "overlapping values, last wins", 89 | inputs: []shared.TagMap{ 90 | {"Environment": {"Dev"}, "Owner": {"jakebark"}}, 91 | {"Owner": {"Jake"}, "CostCenter": {"C-01"}}, 92 | }, 93 | expected: shared.TagMap{"Environment": {"Dev"}, "Owner": {"Jake"}, "CostCenter": {"C-01"}}, 94 | }, 95 | { 96 | name: "overlapping empty value, last wins", 97 | inputs: []shared.TagMap{ 98 | {"Environment": {"Dev"}}, 99 | {"Environment": {}}, 100 | }, 101 | expected: shared.TagMap{"Environment": {}}, 102 | }, 103 | } 104 | 105 | for _, tc := range testCases { 106 | t.Run(tc.name, func(t *testing.T) { 107 | actual := mergeTags(tc.inputs...) 108 | if !reflect.DeepEqual(actual, tc.expected) { 109 | t.Errorf("mergeTags() = %v; want %v", actual, tc.expected) 110 | } 111 | }) 112 | } 113 | } 114 | 115 | func TestConvertCtyValueToString(t *testing.T) { 116 | tests := []struct { 117 | name string 118 | input cty.Value 119 | want string 120 | wantErr bool 121 | }{ 122 | { 123 | name: "string", 124 | input: cty.StringVal("hello world"), 125 | want: "hello world", 126 | }, 127 | { 128 | name: "empty string", 129 | input: cty.StringVal(""), 130 | want: "", 131 | }, 132 | { 133 | name: "number", 134 | input: cty.NumberIntVal(123), 135 | want: "123", 136 | }, 137 | { 138 | name: "float", 139 | input: cty.NumberFloatVal(123.45), 140 | want: "123.45", 141 | }, 142 | { 143 | name: "bool, true", 144 | input: cty.True, 145 | want: "true", 146 | }, 147 | { 148 | name: "bool, false", 149 | input: cty.False, 150 | want: "false", 151 | }, 152 | { 153 | name: "null", 154 | input: cty.NullVal(cty.String), 155 | want: "", 156 | }, 157 | { 158 | name: "unknown value", 159 | input: cty.UnknownVal(cty.String), 160 | wantErr: true, 161 | }, 162 | { 163 | name: "list", 164 | input: cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), 165 | want: `["a","b"]`, 166 | }, 167 | { 168 | name: "map", 169 | input: cty.MapVal(map[string]cty.Value{"key": cty.StringVal("value")}), 170 | want: `{"key":"value"}`, 171 | }, 172 | } 173 | 174 | for _, tc := range tests { 175 | t.Run(tc.name, func(t *testing.T) { 176 | got, err := convertCtyValueToString(tc.input) 177 | if (err != nil) != tc.wantErr { 178 | t.Fatalf("convertCtyValueToString() error = %v, wantErr %v", err, tc.wantErr) 179 | } 180 | if !tc.wantErr && got != tc.want { 181 | t.Errorf("convertCtyValueToString() = %v, want %v", got, tc.want) 182 | } 183 | }) 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /internal/shared/helpers_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "reflect" 5 | "sort" 6 | "testing" 7 | ) 8 | 9 | func TestFilterMissingTags(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | requiredTags TagMap 13 | effectiveTags TagMap 14 | caseInsensitive bool 15 | expectedMissing []string 16 | }{ 17 | { 18 | name: "tags present", 19 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod", "Dev"}}, 20 | effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Prod"}}, 21 | caseInsensitive: false, 22 | expectedMissing: nil, 23 | }, 24 | { 25 | name: "missing key", 26 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, 27 | effectiveTags: TagMap{"Env": {"Prod"}}, 28 | caseInsensitive: false, 29 | expectedMissing: []string{"Owner"}, 30 | }, 31 | { 32 | name: "wrong value", 33 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, 34 | effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Dev"}}, 35 | caseInsensitive: false, 36 | expectedMissing: []string{"Env[Prod]"}, 37 | }, 38 | { 39 | name: "missing key and value", 40 | requiredTags: TagMap{"Env": {"Prod"}}, 41 | effectiveTags: TagMap{"Owner": {"a"}}, 42 | caseInsensitive: false, 43 | expectedMissing: []string{"Env[Prod]"}, 44 | }, 45 | { 46 | name: "tags present, case insensitive", 47 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod", "Dev"}}, 48 | effectiveTags: TagMap{"owner": {"a"}, "env": {"prod"}}, 49 | caseInsensitive: true, 50 | expectedMissing: nil, 51 | }, 52 | { 53 | name: "missing key, case insensitive", 54 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, 55 | effectiveTags: TagMap{"env": {"Prod"}}, 56 | caseInsensitive: true, 57 | expectedMissing: []string{"Owner"}, 58 | }, 59 | { 60 | name: "wrong value, case insensitive", 61 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, 62 | effectiveTags: TagMap{"owner": {"a"}, "env": {"Dev"}}, 63 | caseInsensitive: true, 64 | expectedMissing: []string{"Env[Prod]"}, 65 | }, 66 | { 67 | name: "missing key and value, case insensitive", 68 | requiredTags: TagMap{"Env": {"Prod"}}, 69 | effectiveTags: TagMap{"owner": {"a"}}, 70 | caseInsensitive: true, 71 | expectedMissing: []string{"Env[Prod]"}, 72 | }, 73 | { 74 | name: "no required tags", 75 | requiredTags: TagMap{}, 76 | effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Dev"}}, 77 | caseInsensitive: false, 78 | expectedMissing: nil, 79 | }, 80 | { 81 | name: "no tags", 82 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, 83 | effectiveTags: TagMap{}, 84 | caseInsensitive: false, 85 | expectedMissing: []string{"Owner", "Env[Prod]"}, 86 | }, 87 | { 88 | name: "multiple values required, one present", 89 | requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}}, 90 | effectiveTags: TagMap{"Region": {"us-west-2"}}, 91 | caseInsensitive: false, 92 | expectedMissing: nil, 93 | }, 94 | { 95 | name: "multiple values required, none present", 96 | requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}}, 97 | effectiveTags: TagMap{"Region": {"eu-central-1"}}, 98 | caseInsensitive: false, 99 | expectedMissing: []string{"Region[us-east-1,us-west-2]"}, 100 | }, 101 | { 102 | name: "multiple values required, one present, case insensitive", 103 | requiredTags: TagMap{"Env": {"Prod", "Dev"}}, 104 | effectiveTags: TagMap{"Env": {"prod"}}, 105 | caseInsensitive: true, 106 | expectedMissing: nil, 107 | }, 108 | { 109 | name: "multiple values required, none present, case insensitive", 110 | requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}}, 111 | effectiveTags: TagMap{"region": {"eu-central-1"}}, 112 | caseInsensitive: true, 113 | expectedMissing: []string{"Region[us-east-1,us-west-2]"}, 114 | }, 115 | { 116 | name: "multiple values required, multiple values present", 117 | requiredTags: TagMap{"Env": {"Prod", "Dev"}}, 118 | effectiveTags: TagMap{"Env": {"Prod", "Stage"}}, 119 | caseInsensitive: false, 120 | expectedMissing: nil, 121 | }, 122 | { 123 | name: "multiple values present, none match", 124 | requiredTags: TagMap{"Env": {"Prod"}}, 125 | effectiveTags: TagMap{"Env": {"Dev", "Stage"}}, 126 | caseInsensitive: false, 127 | expectedMissing: []string{"Env[Prod]"}, 128 | }, 129 | } 130 | 131 | for _, tc := range testCases { 132 | t.Run(tc.name, func(t *testing.T) { 133 | actual := FilterMissingTags(tc.requiredTags, tc.effectiveTags, tc.caseInsensitive) 134 | 135 | if actual != nil && tc.expectedMissing != nil { 136 | sort.Strings(actual) 137 | sort.Strings(tc.expectedMissing) 138 | } 139 | 140 | if !reflect.DeepEqual(actual, tc.expectedMissing) { 141 | t.Errorf("FilterMissingTags() = %#v; want %#v", actual, tc.expectedMissing) 142 | } 143 | }) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /internal/terraform/resources_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/google/go-cmp/cmp/cmpopts" 10 | "github.com/hashicorp/hcl/v2" 11 | "github.com/hashicorp/hcl/v2/hclparse" 12 | "github.com/hashicorp/hcl/v2/hclsyntax" 13 | "github.com/jakebark/tag-nag/internal/shared" 14 | "github.com/zclconf/go-cty/cty" 15 | ) 16 | 17 | func TestCheckResourcesForTags_Taggability(t *testing.T) { 18 | parser := hclparse.NewParser() 19 | 20 | tfCode := ` 21 | resource "aws_s3_bucket" "taggable_bucket" { 22 | tags = { 23 | Owner = "test-user" // Missing Environment 24 | } 25 | } 26 | 27 | resource "aws_kms_alias" "non_taggable_alias" { 28 | alias_name = "alias/my-key-alias" 29 | target_key_id = "some-key-id" 30 | } 31 | 32 | resource "aws_instance" "another_taggable" { 33 | ami = "ami-12345" 34 | instance_type = "t2.micro" 35 | tags = { 36 | Owner = "test-user" 37 | Environment = "dev" 38 | } 39 | } 40 | 41 | resource "aws_route53_zone" "unknown_in_schema_should_be_checked" { 42 | name = "example.com" 43 | # Missing all tags 44 | } 45 | ` 46 | 47 | file, diags := parser.ParseHCL([]byte(tfCode), "test.tf") 48 | if diags.HasErrors() { 49 | t.Fatalf("Failed to parse test HCL: %v", diags) 50 | } 51 | 52 | body, ok := file.Body.(*hclsyntax.Body) 53 | if !ok { 54 | t.Fatalf("Could not get HCL syntax body") 55 | } 56 | 57 | requiredTags := shared.TagMap{ 58 | "Owner": {}, 59 | "Environment": {}, 60 | } 61 | lines := strings.Split(tfCode, "\n") 62 | 63 | t.Run("With Taggability Filter", func(t *testing.T) { 64 | taggableMap := map[string]bool{ 65 | "aws_s3_bucket": true, 66 | "aws_kms_alias": false, 67 | "aws_instance": true, 68 | "aws_iam_user": false, // Example of another non-taggable not in the HCL 69 | "aws_route53_zone": true, // Explicitly mark as taggable for test 70 | } 71 | 72 | mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}} 73 | mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)} 74 | 75 | violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap) 76 | 77 | expectedViolations := []Violation{ 78 | {resourceType: "aws_s3_bucket", resourceName: "taggable_bucket", line: 2, missingTags: []string{"Environment"}}, 79 | {resourceType: "aws_route53_zone", resourceName: "unknown_in_schema_should_be_checked", line: 19, missingTags: []string{"Environment", "Owner"}}, // Order might vary 80 | } 81 | 82 | sortViolations(violations) 83 | sortViolations(expectedViolations) // Sort missing tags within each violation for stable comparison 84 | 85 | if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(Violation{})); diff != "" { 86 | t.Errorf("checkResourcesForTags with filter mismatch (-want +got):\n%s", diff) 87 | } 88 | }) 89 | 90 | t.Run("Without Taggability Filter (nil map)", func(t *testing.T) { 91 | taggableMap := map[string]bool(nil) 92 | 93 | mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}} 94 | mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)} 95 | 96 | violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap) 97 | 98 | expectedViolations := []Violation{ 99 | {resourceType: "aws_s3_bucket", resourceName: "taggable_bucket", line: 2, missingTags: []string{"Environment"}}, 100 | {resourceType: "aws_kms_alias", resourceName: "non_taggable_alias", line: 8, missingTags: []string{"Environment", "Owner"}}, 101 | {resourceType: "aws_route53_zone", resourceName: "unknown_in_schema_should_be_checked", line: 19, missingTags: []string{"Environment", "Owner"}}, 102 | } 103 | sortViolations(violations) 104 | sortViolations(expectedViolations) 105 | 106 | if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(Violation{})); diff != "" { 107 | t.Errorf("checkResourcesForTags without filter mismatch (-want +got):\n%s", diff) 108 | } 109 | }) 110 | 111 | t.Run("With Taggability Filter - resource type not in map (should assume taggable)", func(t *testing.T) { 112 | // aws_route53_zone is NOT in this specific taggableMap 113 | taggableMap := map[string]bool{ 114 | "aws_s3_bucket": true, 115 | "aws_kms_alias": false, 116 | "aws_instance": true, 117 | } 118 | 119 | mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}} 120 | mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)} 121 | 122 | violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap) 123 | 124 | expectedViolations := []Violation{ 125 | {resourceType: "aws_s3_bucket", resourceName: "taggable_bucket", line: 2, missingTags: []string{"Environment"}}, 126 | // aws_route53_zone is assumed taggable as it's not in the map with a 'false' entry 127 | {resourceType: "aws_route53_zone", resourceName: "unknown_in_schema_should_be_checked", line: 19, missingTags: []string{"Environment", "Owner"}}, 128 | } 129 | 130 | sortViolations(violations) 131 | sortViolations(expectedViolations) 132 | 133 | if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(Violation{})); diff != "" { 134 | t.Errorf("checkResourcesForTags with incomplete filter mismatch (-want +got):\n%s", diff) 135 | } 136 | }) 137 | } 138 | 139 | // Helper to sort violations for consistent comparison 140 | func sortViolations(violations []Violation) { 141 | for i := range violations { 142 | sort.Strings(violations[i].missingTags) 143 | } 144 | sort.Slice(violations, func(i, j int) bool { 145 | if violations[i].resourceType != violations[j].resourceType { 146 | return violations[i].resourceType < violations[j].resourceType 147 | } 148 | return violations[i].resourceName < violations[j].resourceName 149 | }) 150 | } 151 | -------------------------------------------------------------------------------- /internal/inputs/loader_test.go: -------------------------------------------------------------------------------- 1 | package inputs 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/jakebark/tag-nag/internal/shared" 9 | ) 10 | 11 | func TestProcessConfigFile(t *testing.T) { 12 | testCases := []struct { 13 | name string 14 | configFile string 15 | expectedError bool 16 | expectedTags int 17 | expectedOwner bool 18 | expectedEnvValues []string 19 | expectedSettings Settings 20 | expectedSkips []string 21 | }{ 22 | { 23 | name: "tag keys", 24 | configFile: "../../testdata/config/tag_keys.yml", 25 | expectedError: false, 26 | expectedTags: 2, 27 | expectedOwner: true, 28 | expectedSettings: Settings{ 29 | CaseInsensitive: false, 30 | DryRun: false, 31 | CfnSpec: "", 32 | }, 33 | expectedSkips: []string{}, 34 | }, 35 | { 36 | name: "tag values", 37 | configFile: "../../testdata/config/tag_values.yml", 38 | expectedError: false, 39 | expectedTags: 3, 40 | expectedOwner: true, 41 | expectedEnvValues: []string{"Dev", "Test", "Prod"}, 42 | expectedSettings: Settings{ 43 | CaseInsensitive: false, 44 | DryRun: false, 45 | CfnSpec: "", 46 | }, 47 | expectedSkips: []string{}, 48 | }, 49 | { 50 | name: "full config", 51 | configFile: "../../testdata/config/full_config.yml", 52 | expectedError: false, 53 | expectedTags: 3, 54 | expectedOwner: true, 55 | expectedSettings: Settings{ 56 | CaseInsensitive: true, 57 | DryRun: false, 58 | CfnSpec: "/path/to/spec.json", 59 | }, 60 | expectedSkips: []string{"*.tmp", ".terraform", "test-data/**"}, 61 | }, 62 | { 63 | name: "empty settings", 64 | configFile: "../../testdata/config/empty_settings.yml", 65 | expectedError: false, 66 | expectedTags: 1, 67 | expectedOwner: true, 68 | expectedSettings: Settings{ 69 | CaseInsensitive: false, 70 | DryRun: false, 71 | CfnSpec: "", 72 | }, 73 | expectedSkips: []string{}, 74 | }, 75 | { 76 | name: "yaml extension", 77 | configFile: "../../testdata/config/yaml_extension.yaml", 78 | expectedError: false, 79 | expectedTags: 1, 80 | expectedOwner: true, 81 | expectedSettings: Settings{ 82 | CaseInsensitive: false, 83 | DryRun: false, 84 | CfnSpec: "", 85 | }, 86 | expectedSkips: []string{}, 87 | }, 88 | { 89 | name: "blank config", 90 | configFile: "../../testdata/config/blank_config.yml", 91 | expectedError: false, 92 | expectedTags: 0, 93 | expectedOwner: false, 94 | expectedSettings: Settings{ 95 | CaseInsensitive: false, 96 | DryRun: false, 97 | CfnSpec: "", 98 | }, 99 | expectedSkips: []string{}, 100 | }, 101 | { 102 | name: "invalid syntax", 103 | configFile: "../../testdata/config/invalid_syntax.yml", 104 | expectedError: true, 105 | }, 106 | { 107 | name: "missing tags", 108 | configFile: "../../testdata/config/missing_tags.yml", 109 | expectedError: false, 110 | expectedTags: 0, 111 | expectedOwner: false, 112 | expectedSettings: Settings{ 113 | CaseInsensitive: false, 114 | DryRun: true, 115 | CfnSpec: "", 116 | }, 117 | expectedSkips: []string{"*.tmp"}, 118 | }, 119 | { 120 | name: "invalid structure", 121 | configFile: "../../testdata/config/invalid_structure.yml", 122 | expectedError: true, 123 | }, 124 | { 125 | name: "invalid tag value array", 126 | configFile: "../../testdata/config/tag_array.yml", 127 | expectedError: true, 128 | }, 129 | { 130 | name: "no file", 131 | configFile: "../../testdata/config/does-not-exist.yml", 132 | expectedError: true, 133 | }, 134 | } 135 | 136 | for _, tc := range testCases { 137 | t.Run(tc.name, func(t *testing.T) { 138 | config, err := processConfigFile(tc.configFile) 139 | 140 | // Check error expectation 141 | if tc.expectedError { 142 | if err == nil { 143 | t.Errorf("Expected error but got none") 144 | } 145 | return 146 | } 147 | 148 | if err != nil { 149 | t.Errorf("Unexpected error: %v", err) 150 | return 151 | } 152 | 153 | if config == nil { 154 | t.Errorf("Expected config but got nil") 155 | return 156 | } 157 | 158 | // Check number of tags 159 | if len(config.Tags) != tc.expectedTags { 160 | t.Errorf("Expected %d tags, got %d", tc.expectedTags, len(config.Tags)) 161 | } 162 | 163 | // Check if Owner tag exists 164 | hasOwner := false 165 | var envValues []string 166 | for _, tag := range config.Tags { 167 | if tag.Key == "Owner" { 168 | hasOwner = true 169 | } 170 | if tag.Key == "Environment" { 171 | envValues = tag.Values 172 | } 173 | } 174 | 175 | if hasOwner != tc.expectedOwner { 176 | t.Errorf("Expected Owner tag: %v, got: %v", tc.expectedOwner, hasOwner) 177 | } 178 | 179 | // Check Environment values if specified 180 | if tc.expectedEnvValues != nil { 181 | if !reflect.DeepEqual(envValues, tc.expectedEnvValues) { 182 | t.Errorf("Expected Environment values %v, got %v", tc.expectedEnvValues, envValues) 183 | } 184 | } 185 | 186 | // Check settings 187 | if config.Settings != tc.expectedSettings { 188 | t.Errorf("Expected settings %+v, got %+v", tc.expectedSettings, config.Settings) 189 | } 190 | 191 | // Check skip patterns (handle nil vs empty slice) 192 | configSkips := config.Skip 193 | if configSkips == nil { 194 | configSkips = []string{} 195 | } 196 | if !reflect.DeepEqual(configSkips, tc.expectedSkips) { 197 | t.Errorf("Expected skip patterns %v, got %v", tc.expectedSkips, configSkips) 198 | } 199 | }) 200 | } 201 | } 202 | 203 | func TestFindAndLoadConfigFile(t *testing.T) { 204 | // Save current directory 205 | originalDir, err := os.Getwd() 206 | if err != nil { 207 | t.Fatalf("Failed to get current directory: %v", err) 208 | } 209 | defer os.Chdir(originalDir) 210 | 211 | testCases := []struct { 212 | name string 213 | setupFunc func(t *testing.T, tempDir string) 214 | expectedError bool 215 | expectedConfig bool 216 | expectedTags int 217 | }{ 218 | { 219 | name: "no config files present", 220 | setupFunc: func(t *testing.T, tempDir string) { 221 | // Empty directory 222 | }, 223 | expectedError: false, 224 | expectedConfig: false, 225 | }, 226 | { 227 | name: ".tag-nag.yml present", 228 | setupFunc: func(t *testing.T, tempDir string) { 229 | content := "tags:\n - key: Owner\n" 230 | err := os.WriteFile(".tag-nag.yml", []byte(content), 0644) 231 | if err != nil { 232 | t.Fatalf("Failed to create test config: %v", err) 233 | } 234 | }, 235 | expectedError: false, 236 | expectedConfig: true, 237 | expectedTags: 1, 238 | }, 239 | { 240 | name: ".tag-nag.yaml present", 241 | setupFunc: func(t *testing.T, tempDir string) { 242 | content := "tags:\n - key: Project\n" 243 | err := os.WriteFile(".tag-nag.yaml", []byte(content), 0644) 244 | if err != nil { 245 | t.Fatalf("Failed to create test config: %v", err) 246 | } 247 | }, 248 | expectedError: false, 249 | expectedConfig: true, 250 | expectedTags: 1, 251 | }, 252 | { 253 | name: "both files present (should prefer .yml)", 254 | setupFunc: func(t *testing.T, tempDir string) { 255 | ymlContent := "tags:\n - key: Owner\n - key: Project\n" 256 | yamlContent := "tags:\n - key: Environment\n" 257 | 258 | err := os.WriteFile(".tag-nag.yml", []byte(ymlContent), 0644) 259 | if err != nil { 260 | t.Fatalf("Failed to create .yml config: %v", err) 261 | } 262 | 263 | err = os.WriteFile(".tag-nag.yaml", []byte(yamlContent), 0644) 264 | if err != nil { 265 | t.Fatalf("Failed to create .yaml config: %v", err) 266 | } 267 | }, 268 | expectedError: false, 269 | expectedConfig: true, 270 | expectedTags: 2, // Should use .yml file (2 tags), not .yaml file (1 tag) 271 | }, 272 | { 273 | name: "invalid config file", 274 | setupFunc: func(t *testing.T, tempDir string) { 275 | content := "tags:\n - key: Owner\n values: [invalid" 276 | err := os.WriteFile(".tag-nag.yml", []byte(content), 0644) 277 | if err != nil { 278 | t.Fatalf("Failed to create invalid config: %v", err) 279 | } 280 | }, 281 | expectedError: true, 282 | }, 283 | } 284 | 285 | for _, tc := range testCases { 286 | t.Run(tc.name, func(t *testing.T) { 287 | // Create temporary directory for each test 288 | tempDir, err := os.MkdirTemp("", "tag-nag-test") 289 | if err != nil { 290 | t.Fatalf("Failed to create temp dir: %v", err) 291 | } 292 | defer os.RemoveAll(tempDir) 293 | 294 | // Change to temp directory 295 | err = os.Chdir(tempDir) 296 | if err != nil { 297 | t.Fatalf("Failed to change to temp dir: %v", err) 298 | } 299 | 300 | // Setup test scenario 301 | tc.setupFunc(t, tempDir) 302 | 303 | // Test the function 304 | config, err := FindAndLoadConfigFile() 305 | 306 | // Check error expectation 307 | if tc.expectedError { 308 | if err == nil { 309 | t.Errorf("Expected error but got none") 310 | } 311 | return 312 | } 313 | 314 | if err != nil { 315 | t.Errorf("Unexpected error: %v", err) 316 | return 317 | } 318 | 319 | // Check config presence 320 | if tc.expectedConfig { 321 | if config == nil { 322 | t.Errorf("Expected config but got nil") 323 | return 324 | } 325 | if len(config.Tags) != tc.expectedTags { 326 | t.Errorf("Expected %d tags, got %d", tc.expectedTags, len(config.Tags)) 327 | } 328 | } else { 329 | if config != nil { 330 | t.Errorf("Expected no config but got: %+v", config) 331 | } 332 | } 333 | }) 334 | } 335 | } 336 | 337 | func TestConvertToTagMap(t *testing.T) { 338 | testCases := []struct { 339 | name string 340 | config Config 341 | expected shared.TagMap 342 | }{ 343 | { 344 | name: "empty config", 345 | config: Config{ 346 | Tags: []TagDefinition{}, 347 | }, 348 | expected: shared.TagMap{}, 349 | }, 350 | { 351 | name: "tags without values", 352 | config: Config{ 353 | Tags: []TagDefinition{ 354 | {Key: "Owner"}, 355 | {Key: "Project"}, 356 | }, 357 | }, 358 | expected: shared.TagMap{ 359 | "Owner": []string{}, 360 | "Project": []string{}, 361 | }, 362 | }, 363 | { 364 | name: "tags with values", 365 | config: Config{ 366 | Tags: []TagDefinition{ 367 | {Key: "Owner"}, 368 | {Key: "Environment", Values: []string{"Dev", "Test", "Prod"}}, 369 | {Key: "Project"}, 370 | }, 371 | }, 372 | expected: shared.TagMap{ 373 | "Owner": []string{}, 374 | "Environment": []string{"Dev", "Test", "Prod"}, 375 | "Project": []string{}, 376 | }, 377 | }, 378 | { 379 | name: "mixed values", 380 | config: Config{ 381 | Tags: []TagDefinition{ 382 | {Key: "Owner", Values: []string{"Alice", "Bob"}}, 383 | {Key: "Environment", Values: []string{"Prod"}}, 384 | {Key: "Project"}, 385 | }, 386 | }, 387 | expected: shared.TagMap{ 388 | "Owner": []string{"Alice", "Bob"}, 389 | "Environment": []string{"Prod"}, 390 | "Project": []string{}, 391 | }, 392 | }, 393 | } 394 | 395 | for _, tc := range testCases { 396 | t.Run(tc.name, func(t *testing.T) { 397 | result := tc.config.convertToTagMap() 398 | 399 | // Check each key individually to handle nil vs empty slice differences 400 | if len(result) != len(tc.expected) { 401 | t.Errorf("Expected %d keys, got %d", len(tc.expected), len(result)) 402 | return 403 | } 404 | 405 | for key, expectedValues := range tc.expected { 406 | actualValues, exists := result[key] 407 | if !exists { 408 | t.Errorf("Expected key %s not found in result", key) 409 | continue 410 | } 411 | 412 | // Handle nil vs empty slice 413 | if actualValues == nil { 414 | actualValues = []string{} 415 | } 416 | if expectedValues == nil { 417 | expectedValues = []string{} 418 | } 419 | 420 | if !reflect.DeepEqual(actualValues, expectedValues) { 421 | t.Errorf("Key %s: expected %v, got %v", key, expectedValues, actualValues) 422 | } 423 | } 424 | }) 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | const binaryName = "tag-nag" 13 | 14 | type testCases struct { 15 | name string 16 | filePathOrDir string 17 | cliArgs []string 18 | expectedExitCode int 19 | expectedError bool 20 | expectedOutput []string 21 | } 22 | 23 | func TestMain(m *testing.M) { 24 | cmd := exec.Command("go", "build", "-o", binaryName) 25 | if err := cmd.Run(); err != nil { 26 | fmt.Fprintf(os.Stderr, "Failed to build %s: %v\n", binaryName, err) 27 | os.Exit(1) 28 | } 29 | 30 | exitVal := m.Run() 31 | os.Remove(binaryName) 32 | os.Exit(exitVal) 33 | } 34 | 35 | func runTagNag(t *testing.T, args ...string) (string, error, int) { 36 | t.Helper() 37 | fullArgs := append([]string{"./" + binaryName}, args...) 38 | cmd := exec.Command(fullArgs[0], fullArgs[1:]...) 39 | var outbuf, errbuf bytes.Buffer 40 | cmd.Stdout = &outbuf 41 | cmd.Stderr = &errbuf 42 | 43 | err := cmd.Run() 44 | stdout := outbuf.String() 45 | stderr := errbuf.String() 46 | 47 | fullOutput := stdout 48 | if stderr != "" { 49 | fullOutput += "\n" + stderr 50 | } 51 | 52 | exitCode := 0 53 | if err != nil { 54 | if exitError, ok := err.(*exec.ExitError); ok { 55 | exitCode = exitError.ExitCode() 56 | } else { 57 | t.Fatalf("Command execution failed with non-exit error: %v, output: %s", err, fullOutput) 58 | } 59 | } 60 | return fullOutput, err, exitCode 61 | } 62 | 63 | func TestInputs(t *testing.T) { 64 | testCases := []testCases{ 65 | { 66 | name: "no dir", 67 | filePathOrDir: "", 68 | cliArgs: []string{"--tags", "Owner"}, 69 | expectedExitCode: 1, 70 | expectedError: true, 71 | expectedOutput: []string{"Error: specify a directory or file to scan"}, 72 | }, 73 | { 74 | name: "no tags", 75 | filePathOrDir: "testdata/terraform/tags.tf", 76 | cliArgs: []string{"nonexistent.yml"}, 77 | expectedExitCode: 1, 78 | expectedError: true, 79 | expectedOutput: []string{"specify required tags using --tags or create a .tag-nag.yml config file"}, 80 | }, 81 | 82 | { 83 | name: "dry run", 84 | filePathOrDir: "testdata/terraform/tags.tf", 85 | cliArgs: []string{"--tags", "Owner,Environment,Project", "--dry-run"}, 86 | expectedExitCode: 0, 87 | expectedError: false, 88 | expectedOutput: []string{"Dry-run:", `aws_s3_bucket "this"`, "Missing tags: Project"}, 89 | }, 90 | } 91 | for _, tc := range testCases { 92 | t.Run(tc.name, func(t *testing.T) { 93 | var argsForRun []string 94 | if tc.filePathOrDir != "" { 95 | argsForRun = append(argsForRun, tc.filePathOrDir) 96 | } 97 | argsForRun = append(argsForRun, tc.cliArgs...) 98 | 99 | output, err, exitCode := runTagNag(t, argsForRun...) 100 | 101 | if tc.expectedError && err == nil { 102 | t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output) 103 | } 104 | if !tc.expectedError && err != nil { 105 | t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output) 106 | } 107 | 108 | if exitCode != tc.expectedExitCode { 109 | t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output) 110 | } 111 | 112 | for _, expectedStr := range tc.expectedOutput { 113 | if !strings.Contains(output, expectedStr) { 114 | t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output) 115 | } 116 | } 117 | }) 118 | } 119 | } 120 | 121 | func TestTerraform(t *testing.T) { 122 | testCases := []testCases{ 123 | { 124 | name: "tags", 125 | filePathOrDir: "testdata/terraform/tags.tf", 126 | cliArgs: []string{"--tags", "Owner,Environment"}, 127 | expectedExitCode: 0, 128 | expectedError: false, 129 | expectedOutput: []string{"No tag violations found"}, 130 | }, 131 | { 132 | name: "missing tags", 133 | filePathOrDir: "testdata/terraform/tags.tf", 134 | cliArgs: []string{"--tags", "Owner,Environment,Project"}, 135 | expectedExitCode: 1, 136 | expectedError: true, 137 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Project"}, 138 | }, 139 | { 140 | name: "no tags", 141 | filePathOrDir: "testdata/terraform/no_tags.tf", 142 | cliArgs: []string{"--tags", "Owner, Environment"}, 143 | expectedExitCode: 1, 144 | expectedError: true, 145 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment, Owner"}, 146 | }, 147 | { 148 | name: "case insensitive", 149 | filePathOrDir: "testdata/terraform/tags.tf", 150 | cliArgs: []string{"--tags", "owner,environment", "-c"}, 151 | expectedExitCode: 0, 152 | expectedError: false, 153 | expectedOutput: []string{"No tag violations found"}, 154 | }, 155 | { 156 | name: "lower case", 157 | filePathOrDir: "testdata/terraform/tags.tf", 158 | cliArgs: []string{"--tags", "owner"}, 159 | expectedExitCode: 1, 160 | expectedError: true, 161 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: owner"}, 162 | }, 163 | { 164 | name: "tag values", 165 | filePathOrDir: "testdata/terraform/tags.tf", 166 | cliArgs: []string{"--tags", "Owner,Environment[dev,prod]"}, 167 | expectedExitCode: 0, 168 | expectedError: false, 169 | expectedOutput: []string{"No tag violations found"}, 170 | }, 171 | { 172 | name: "missing tag value", 173 | filePathOrDir: "testdata/terraform/tags.tf", 174 | cliArgs: []string{"--tags", "Owner,Environment[test]"}, 175 | expectedExitCode: 1, 176 | expectedError: true, 177 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment[test]"}, 178 | }, 179 | { 180 | name: "tag values case insensitive", 181 | filePathOrDir: "testdata/terraform/tags.tf", 182 | cliArgs: []string{"--tags", "Owner,Environment[Dev,Prod]", "-c"}, 183 | expectedExitCode: 0, 184 | expectedError: false, 185 | expectedOutput: []string{"No tag violations found"}, 186 | }, 187 | { 188 | name: "provider", 189 | filePathOrDir: "testdata/terraform/provider.tf", 190 | cliArgs: []string{"--tags", "Owner,Environment,Project,Source"}, 191 | expectedExitCode: 0, 192 | expectedError: false, 193 | expectedOutput: []string{"Found Terraform default tags for provider aws", "No tag violations found"}, 194 | }, 195 | { 196 | name: "provider", 197 | filePathOrDir: "testdata/terraform/provider.tf", 198 | cliArgs: []string{"--tags", "Owner,Environment,Project,Source"}, 199 | expectedExitCode: 0, 200 | expectedError: false, 201 | expectedOutput: []string{"Found Terraform default tags for provider aws: [Project, Source]", "No tag violations found"}, 202 | }, 203 | { 204 | name: "provider case insensitive", 205 | filePathOrDir: "testdata/terraform/provider.tf", 206 | cliArgs: []string{"--tags", "owner,environment,project,source", "-c"}, 207 | expectedExitCode: 0, 208 | expectedError: false, 209 | expectedOutput: []string{"Found Terraform default tags for provider aws: [project, source]", "No tag violations found"}, 210 | }, 211 | { 212 | name: "provider tag values", 213 | filePathOrDir: "testdata/terraform/provider.tf", 214 | cliArgs: []string{"--tags", "Owner,Environment[dev,prod],Project,Source[my-repo]"}, 215 | expectedExitCode: 0, 216 | expectedError: false, 217 | expectedOutput: []string{"Found Terraform default tags for provider aws: [Project, Source]", "No tag violations found"}, 218 | }, 219 | { 220 | name: "variable tags", 221 | filePathOrDir: "testdata/terraform/referenced_tags.tf", 222 | cliArgs: []string{"--tags", "Owner,Environment"}, 223 | expectedExitCode: 0, 224 | expectedError: false, 225 | expectedOutput: []string{"No tag violations found"}, 226 | }, 227 | { 228 | name: "variable value", 229 | filePathOrDir: "testdata/terraform/referenced_values.tf", 230 | cliArgs: []string{"--tags", "Owner[jakebark],Environment"}, 231 | expectedExitCode: 0, 232 | expectedError: false, 233 | expectedOutput: []string{"No tag violations found"}, 234 | }, 235 | { 236 | name: "local value", 237 | filePathOrDir: "testdata/terraform/referenced_values.tf", 238 | cliArgs: []string{"--tags", "Owner,Environment[dev,prod]"}, 239 | expectedExitCode: 0, 240 | expectedError: false, 241 | expectedOutput: []string{"No tag violations found"}, 242 | }, 243 | { 244 | name: "variable value case insensitive", 245 | filePathOrDir: "testdata/terraform/referenced_values.tf", 246 | cliArgs: []string{"--tags", "Owner[Jakebark],Environment", "-c"}, 247 | expectedExitCode: 0, 248 | expectedError: false, 249 | expectedOutput: []string{"No tag violations found"}, 250 | }, 251 | { 252 | name: "local value case insensitive", 253 | filePathOrDir: "testdata/terraform/referenced_values.tf", 254 | cliArgs: []string{"--tags", "Owner,Environment[DEV,PROD]", "-c"}, 255 | expectedExitCode: 0, 256 | expectedError: false, 257 | expectedOutput: []string{"No tag violations found"}, 258 | }, 259 | { 260 | name: "interpolation", 261 | filePathOrDir: "testdata/terraform/referenced_values.tf", 262 | cliArgs: []string{"--tags", "Owner,Environment,Project[112233],Source[my-repo]"}, 263 | expectedExitCode: 0, 264 | expectedError: false, 265 | expectedOutput: []string{"No tag violations found"}, 266 | }, 267 | { 268 | name: "interpolation missing value", 269 | filePathOrDir: "testdata/terraform/referenced_values.tf", 270 | cliArgs: []string{"--tags", "Owner,Environment,Project[112233],Source[not-my-repo]"}, 271 | expectedExitCode: 1, 272 | expectedError: true, 273 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Source[not-my-repo]"}, 274 | }, 275 | { 276 | name: "example repo", 277 | filePathOrDir: "testdata/terraform/example_repo", 278 | cliArgs: []string{"--tags", "Owner,Environment"}, 279 | expectedExitCode: 1, 280 | expectedError: true, 281 | expectedOutput: []string{"Found Terraform default tags for provider aws: [Environment, Owner, Source]", `aws_s3_bucket "baz"`, "Found 1 tag violation(s)"}, 282 | }, 283 | { 284 | name: "ignore", 285 | filePathOrDir: "testdata/terraform/ignore.tf", 286 | cliArgs: []string{"--tags", "Owner,Environment,Project"}, 287 | expectedExitCode: 0, 288 | expectedError: false, 289 | expectedOutput: []string{`aws_s3_bucket "this" skipped`}, 290 | }, 291 | { 292 | name: "ignore all", 293 | filePathOrDir: "testdata/terraform/ignore_all.tf", 294 | cliArgs: []string{"--tags", "Owner,Environment,Project"}, 295 | expectedExitCode: 0, 296 | expectedError: false, 297 | expectedOutput: []string{`aws_s3_bucket "this" skipped`}, 298 | }, 299 | { 300 | name: "lower function", 301 | filePathOrDir: "testdata/terraform/function.tf", 302 | cliArgs: []string{"--tags", "Environment[dev]"}, 303 | expectedExitCode: 0, 304 | expectedError: false, 305 | expectedOutput: []string{"No tag violations found"}, 306 | }, 307 | { 308 | name: "skip file", 309 | filePathOrDir: "testdata/terraform", 310 | cliArgs: []string{"--tags", "Owner,Environment,Project", "-s", "testdata/terraform/tags.tf"}, 311 | expectedExitCode: 1, 312 | expectedError: true, 313 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment, Owner"}, 314 | }, 315 | { 316 | name: "skip directory", 317 | filePathOrDir: "testdata", 318 | cliArgs: []string{"--tags", "Owner,Environment,Project", "-s", "testdata/terraform"}, 319 | expectedExitCode: 1, 320 | expectedError: true, 321 | expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"}, 322 | }, 323 | } 324 | 325 | for _, tc := range testCases { 326 | t.Run(tc.name, func(t *testing.T) { 327 | argsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...) 328 | output, err, exitCode := runTagNag(t, argsForRun...) 329 | 330 | if tc.expectedError && err == nil { 331 | t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output) 332 | } 333 | if !tc.expectedError && err != nil { 334 | t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output) 335 | } 336 | 337 | if exitCode != tc.expectedExitCode { 338 | t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output) 339 | } 340 | 341 | for _, expectedStr := range tc.expectedOutput { 342 | if !strings.Contains(output, expectedStr) { 343 | t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output) 344 | } 345 | } 346 | }) 347 | } 348 | } 349 | 350 | func TestCloudFormation(t *testing.T) { 351 | testCases := []testCases{ 352 | { 353 | name: "yml", 354 | filePathOrDir: "testdata/cloudformation/tags.yml", 355 | cliArgs: []string{"--tags", "Owner,Environment"}, 356 | expectedExitCode: 0, 357 | expectedError: false, 358 | expectedOutput: []string{"No tag violations found"}, 359 | }, 360 | { 361 | name: "yaml", 362 | filePathOrDir: "testdata/cloudformation/tags.yaml", 363 | cliArgs: []string{"--tags", "Owner,Environment"}, 364 | expectedExitCode: 0, 365 | expectedError: false, 366 | expectedOutput: []string{"No tag violations found"}, 367 | }, 368 | { 369 | name: "json", 370 | filePathOrDir: "testdata/cloudformation/tags.json", 371 | cliArgs: []string{"--tags", "Owner,Environment"}, 372 | expectedExitCode: 0, 373 | expectedError: false, 374 | expectedOutput: []string{"No tag violations found"}, 375 | }, 376 | { 377 | name: "yaml missing tags", 378 | filePathOrDir: "testdata/cloudformation/tags.yml", 379 | cliArgs: []string{"--tags", "Owner,Environment,Project"}, 380 | expectedExitCode: 1, 381 | expectedError: true, 382 | expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"}, 383 | }, 384 | { 385 | name: "json missing tags", 386 | filePathOrDir: "testdata/cloudformation/tags.json", 387 | cliArgs: []string{"--tags", "Owner,Environment,Project"}, 388 | expectedExitCode: 1, 389 | expectedError: true, 390 | expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"}, 391 | }, 392 | { 393 | name: "case insensitive", 394 | filePathOrDir: "testdata/cloudformation/tags.yml", 395 | cliArgs: []string{"--tags", "owner,environment", "-c"}, 396 | expectedExitCode: 0, 397 | expectedError: false, 398 | expectedOutput: []string{"No tag violations found"}, 399 | }, 400 | { 401 | name: "lower case", 402 | filePathOrDir: "testdata/cloudformation/tags.yml", 403 | cliArgs: []string{"--tags", "owner"}, 404 | expectedExitCode: 1, 405 | expectedError: true, 406 | expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: owner"}, 407 | }, 408 | } 409 | 410 | for _, tc := range testCases { 411 | t.Run(tc.name, func(t *testing.T) { 412 | argsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...) 413 | output, err, exitCode := runTagNag(t, argsForRun...) 414 | 415 | if tc.expectedError && err == nil { 416 | t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output) 417 | } 418 | if !tc.expectedError && err != nil { 419 | t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output) 420 | } 421 | 422 | if exitCode != tc.expectedExitCode { 423 | t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output) 424 | } 425 | 426 | for _, expectedStr := range tc.expectedOutput { 427 | if !strings.Contains(output, expectedStr) { 428 | t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output) 429 | } 430 | } 431 | }) 432 | } 433 | } 434 | --------------------------------------------------------------------------------