Repository: jakebark/tag-nag Branch: main Commit: fc7e0da543e8 Files: 79 Total size: 133.5 KB Directory structure: gitextract_b5nvfihg/ ├── .github/ │ ├── dependabot.yml │ └── workflows/ │ ├── docker-hub.yml │ ├── go_tests.yml │ └── semgrep.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── examples/ │ ├── .tag-nag.yml │ ├── codebuild.yml │ ├── github.yml │ └── gitlab.yml ├── go.mod ├── go.sum ├── internal/ │ ├── cloudformation/ │ │ ├── helpers.go │ │ ├── helpers_test.go │ │ ├── process.go │ │ ├── resources.go │ │ ├── resources_test.go │ │ ├── scan.go │ │ ├── spec_loader.go │ │ └── spec_loader_test.go │ ├── config/ │ │ └── config.go │ ├── inputs/ │ │ ├── inputs.go │ │ ├── inputs_test.go │ │ ├── loader.go │ │ └── loader_test.go │ ├── output/ │ │ ├── formatter.go │ │ ├── formatter_test.go │ │ ├── json.go │ │ ├── json_test.go │ │ ├── junit.go │ │ ├── junit_test.go │ │ ├── process.go │ │ ├── sarif.go │ │ ├── sarif_test.go │ │ ├── text.go │ │ └── text_test.go │ ├── shared/ │ │ ├── helpers.go │ │ ├── helpers_test.go │ │ └── types.go │ └── terraform/ │ ├── default_tags.go │ ├── default_tags_test.go │ ├── helpers.go │ ├── helpers_test.go │ ├── process.go │ ├── references.go │ ├── resources.go │ ├── resources_test.go │ ├── scan.go │ ├── scan_test.go │ └── types.go ├── main.go ├── main_test.go └── testdata/ ├── cloudformation/ │ ├── tags.json │ ├── tags.yaml │ └── tags.yml ├── config/ │ ├── blank_config.yml │ ├── empty_settings.yml │ ├── full_config.yml │ ├── invalid_structure.yml │ ├── invalid_syntax.yml │ ├── missing_tags.yml │ ├── tag_array.yml │ ├── tag_keys.yml │ ├── tag_values.yml │ └── yaml_extension.yaml └── terraform/ ├── example_repo/ │ ├── locals.tf │ ├── main.tf │ ├── provider.tf │ └── variables.tf ├── functions.tf ├── ignore.tf ├── ignore_all.tf ├── no_tags.tf ├── provider.tf ├── referenced_tags.tf ├── referenced_values.tf └── tags.tf ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/dependabot.yml ================================================ version: 2 updates: - package-ecosystem: "gomod" directory: "/" schedule: interval: "monthly" open-pull-requests-limit: 5 labels: - "dependencies" groups: golang-x: patterns: ["golang.org/x/*"] - package-ecosystem: "github-actions" directory: "/" schedule: interval: "monthly" open-pull-requests-limit: 5 labels: - "dependencies" ================================================ FILE: .github/workflows/docker-hub.yml ================================================ name: publish to docker hub on: release: types: [published] jobs: build-and-push: runs-on: ubuntu-latest permissions: contents: read steps: - name: Check out the repo uses: actions/checkout@v6 - name: Log in to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Extract metadata (tags, labels) for Docker id: meta uses: docker/metadata-action@v5 with: images: jakebark/tag-nag - name: Build and push Docker image id: push uses: docker/build-push-action@v6 with: context: . file: ./Dockerfile push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} build-args: | TERRAFORM_VERSION=${{ vars.TERRAFORM_VERSION }} ================================================ FILE: .github/workflows/go_tests.yml ================================================ name: go test on: pull_request: branches: [ main ] jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, macos-latest] go-version: ['1.21', '1.22', '1.23', '1.24'] steps: - name: checkout code uses: actions/checkout@v6 - name: setup go uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} cache: true - name: run go tests run: go test -v ./... ================================================ FILE: .github/workflows/semgrep.yml ================================================ name: semgrep on: pull_request: branches: [ main ] jobs: semgrep: runs-on: ubuntu-latest container: image: semgrep/semgrep steps: - name: checkout code uses: actions/checkout@v6 - name: run semgrep run: semgrep ci ================================================ FILE: .gitignore ================================================ *.exe *.exe~ *.dll *.so *.dylib *.test **/test/* todo.md # Output of the go coverage tool, specifically when used with LiteIDE *.out # Dependency directories (remove the comment below to include it) # vendor/ # Go workspace file go.work go.work.sum # env file .env .DS_Store ================================================ FILE: Dockerfile ================================================ FROM golang:1.22 AS builder WORKDIR /app COPY go.mod go.sum ./ RUN go mod download RUN go mod tidy COPY . . RUN go build -o tag-nag FROM debian:stable-slim ARG TERRAFORM_VERSION RUN apt-get update && \ apt-get install -y --no-install-recommends \ wget \ unzip \ ca-certificates \ git && \ rm -rf /var/lib/apt/lists/* RUN wget "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \ unzip "terraform_${TERRAFORM_VERSION}_linux_amd64.zip" -d /usr/local/bin && \ rm "terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \ chmod +x /usr/local/bin/terraform RUN groupadd -r appgroup && \ useradd --no-log-init -r -g appgroup appuser COPY --from=builder /app/tag-nag /usr/local/bin/tag-nag USER appuser WORKDIR /workspace ENTRYPOINT ["tag-nag"] CMD ["--help"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 jakebark Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # tag-nag Validate AWS tags in Terraform and CloudFormation. ## Installation ```bash go install github.com/jakebark/tag-nag@latest ``` You may need to set [GOPATH](https://go.dev/wiki/SettingGOPATH). ## Commands Tag-nag will search a file or directory for tag keys. Directory search is recursive. ```bash tag-nag --tags "Key1,Key2" tag-nag main.tf --tags "Owner" # run against a file tag-nag ./my_project --tags "Owner,Environment" # run against a directory tag-nag . --tags "Owner", "Environment" # will take string or list ``` Search for tag keys *and* tag values ```bash tag-nag --tags "Key[Value]" tag-nag main.tf --tags "Owner[Jake]" tag-nag main.tf --tags "Owner[Jake],Environment" # mixed search possible tag-nag main.tf --tags "Owner[Jake],Environment[Dev,Prod]" # multiple options for tag values ``` Flags ```bash -c --case-insensitive -d --dry-run # will always exit successfully --cfn-spec ~/path/to/CloudFormationResourceSpecification.json # path to Cfn spec file, filters taggable resources -s --skip "file.tf, path/to/directory" # skip files and directories -o --output json # output to json (default is text) ``` ## Config file The above commands can be issued with a `.tag-nag.yml` file in the same directory where tag-nag is run. See the [example .tag-nag.yml file](./examples/.tag-nag.yml). ## Skip Checks Skip file ```hcl #tag-nag ignore-all ``` Terraform ```hcl resource "aws_s3_bucket" "this" { #tag-nag ignore bucket = "that" } ``` CloudFormation ```yaml EC2Instance: #tag-nag ignore Type: "AWS::EC2::Instance" Properties: ImageId: ami-12a34b InstanceType: c1.xlarge ``` ## Filtering taggable resources Some AWS resources cannot be tagged. To filter out these resources with Terraform, run tag-nag against an initialised directory (`terraform init`). 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. ## Docker Run ```bash docker pull jakebark/tag-nag:latest docker run --rm -v $(pwd):/workspace -w /workspace jakebark/tag-nag \ . --tags "Owner,Environment" ``` Interactive shell ```bash docker pull jakebark/tag-nag:latest docker run -it --rm \ -v "$(pwd)":/workspace \ -w /workspace \ --entrypoint /bin/sh jakebark/tag-nag:latest ``` The image contains terraform, allowing `terraform init` to be run, if required. ```bash docker pull jakebark/tag-nag:latest docker run --rm -v $(pwd):/workspace -w /workspace \ --entrypoint /bin/sh jakebark/tag-nag:latest \ -c "terraform init -input=false -no-color && tag-nag\ . --tags 'Owner,Environment'" ``` ## CI/CD Example CI files: - [GitHub](./examples/github.yml) - [GitLab](./examples/gitlab.yml) - [AWS CodeBuild](./examples/codebuild.yml) ## Related Resources - [pkg.go.dev/github.com/jakebark/tag-nag](https://pkg.go.dev/github.com/jakebark/tag-nag)
tag:nag
================================================ FILE: examples/.tag-nag.yml ================================================ tags: - key: Owner - key: Environment values: [Dev, Test, Prod] - key: Project settings: case_insensitive: false dry_run: false cfn_spec: "~/path/to/CloudFormationResourceSpecification.json" # remove if not using skip: - file.tf - "test-data/**" # keep quotes for wildcard/glob pattern - "*.tmp" - .terraform - .git ================================================ FILE: examples/codebuild.yml ================================================ ## codebuild image 'jakebar/tag-nag:$latest' version: 0.2 phases: build: commands: - cd "$CODEBUILD_SRC_DIR" - terraform init -backend=false # remove for CloudFormation - tag-nag . --tags "tags" ================================================ FILE: examples/github.yml ================================================ name: tag-nag on: pull_request: branches: [main] jobs: tag-nag: runs-on: ubuntu-latest container: image: jakebark/tag-nag:latest steps: - name: checkout code uses: actions/checkout@v4 - name: run terraform init # remove for CloudFormation run: terraform init -backend=false - name: run tag-nag run: tag-nag . --tags "tags" ================================================ FILE: examples/gitlab.yml ================================================ stages: - validate tag-nag: stage: validate image: jakebark/tag-nag:latest script: - terraform init -backend=false # remove for CloudFormation - tag-nag . --tags "tags" ================================================ FILE: go.mod ================================================ module github.com/jakebark/tag-nag go 1.22 require ( github.com/hashicorp/hcl/v2 v2.23.0 github.com/spf13/pflag v1.0.6 github.com/zclconf/go-cty v1.13.0 gopkg.in/yaml.v3 v3.0.1 ) require ( github.com/agext/levenshtein v1.2.1 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect golang.org/x/mod v0.8.0 // indirect golang.org/x/sys v0.5.0 // indirect golang.org/x/text v0.11.0 // indirect golang.org/x/tools v0.6.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw= github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0= github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4= golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: internal/cloudformation/helpers.go ================================================ package cloudformation import ( "os" "strings" "github.com/jakebark/tag-nag/internal/config" "gopkg.in/yaml.v3" ) // mapNodes converts a yaml mapping node into a go map func mapNodes(node *yaml.Node) map[string]*yaml.Node { m := make(map[string]*yaml.Node) if node == nil || node.Kind != yaml.MappingNode { return m } for i := 0; i < len(node.Content); i += 2 { keyNode := node.Content[i] valueNode := node.Content[i+1] m[keyNode.Value] = valueNode } return m } // findMapNode parses a yaml block and returns the value, when given the key func findMapNode(node *yaml.Node, key string) *yaml.Node { if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { node = node.Content[0] } if node.Kind != yaml.MappingNode { return nil } for i := 0; i < len(node.Content); i += 2 { k := node.Content[i] v := node.Content[i+1] if k.Value == key { return v } } return nil } // parseYAML unmarshal yaml and return a pointer to the root of the node func parseYAML(filePath string) (*yaml.Node, error) { data, err := os.ReadFile(filePath) if err != nil { return nil, err } var root yaml.Node if err := yaml.Unmarshal(data, &root); err != nil { return nil, err } if root.Kind == yaml.DocumentNode && len(root.Content) > 0 { root = *root.Content[0] } return &root, nil } func skipResource(node *yaml.Node, lines []string) bool { index := node.Line - 2 if index < len(lines) { if strings.Contains(lines[index], config.TagNagIgnore) { return true } } return false } ================================================ FILE: internal/cloudformation/helpers_test.go ================================================ package cloudformation import ( "testing" "gopkg.in/yaml.v3" ) // helper to create a yaml.Node for testing func createYamlNode(t *testing.T, yamlStr string) *yaml.Node { t.Helper() var node yaml.Node err := yaml.Unmarshal([]byte(yamlStr), &node) if err != nil { t.Fatalf("Failed to unmarshal test YAML: %v\nYAML:\n%s", err, yamlStr) } if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { return node.Content[0] } return &node } func testMapNodes(t *testing.T) { tests := []struct { name string inputYAML string expectedKeys []string }{ { name: "simple map", inputYAML: `Key1: Value1\nKey2: 123`, expectedKeys: []string{"Key1", "Key2"}, }, { name: "nested map", inputYAML: `Key1: Value1\nKey2:\n Nested1: NestedValue`, expectedKeys: []string{"Key1", "Key2"}, }, { name: "empty map", inputYAML: `{}`, expectedKeys: []string{}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { node := createYamlNode(t, tc.inputYAML) mapped := mapNodes(node) gotKeys := make([]string, 0, len(mapped)) for k := range mapped { gotKeys = append(gotKeys, k) } if len(gotKeys) != len(tc.expectedKeys) { t.Errorf("mapNodes() returned map with %d keys, expected %d. Got keys: %v", len(gotKeys), len(tc.expectedKeys), gotKeys) return } }) } } func testFindMapNode(t *testing.T) { yamlContent := ` RootKey: RootValue Map1: NestedKey1: NestedValue1 NestedKey2: 123 Map2: {} List1: - itemA - itemB ` rootNode := createYamlNode(t, yamlContent) tests := []struct { name string node *yaml.Node key string expectedNil bool expectedKind yaml.Kind expectedValue string }{ { name: "root key to root value", node: rootNode, key: "RootKey", expectedNil: false, expectedKind: yaml.ScalarNode, expectedValue: "RootValue", }, { name: "map key to map values", node: rootNode, key: "Map1", expectedNil: false, expectedKind: yaml.MappingNode, }, { name: "map key to empty map value", node: rootNode, key: "Map2", expectedNil: false, expectedKind: yaml.MappingNode, }, { name: "list key to list values", node: rootNode, key: "List1", expectedNil: false, expectedKind: yaml.SequenceNode, }, { name: "missing key", node: rootNode, key: "MissingKey", expectedNil: true, }, { name: "search other node", node: findMapNode(rootNode, "List1"), key: "itemA", expectedNil: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := findMapNode(tc.node, tc.key) isNil := got == nil if isNil != tc.expectedNil { t.Fatalf("findMapNode(key=%q) returned nil? %t, expectedNil %t", tc.key, isNil, tc.expectedNil) } if !tc.expectedNil { if got.Kind != tc.expectedKind { t.Errorf("findMapNode(key=%q) returned node kind %v, expected %v", tc.key, got.Kind, tc.expectedKind) } if tc.expectedKind == yaml.ScalarNode && got.Value != tc.expectedValue { t.Errorf("findMapNode(key=%q) returned scalar value %q, expected %q", tc.key, got.Value, tc.expectedValue) } } }) } } ================================================ FILE: internal/cloudformation/process.go ================================================ package cloudformation import ( "fmt" "log" "os" "path/filepath" "slices" "strings" "github.com/jakebark/tag-nag/internal/config" "github.com/jakebark/tag-nag/internal/shared" ) // ProcessDirectory walks all cfn files in a directory, then returns violations func ProcessDirectory(directoryPath string, requiredTags map[string][]string, caseInsensitive bool, specFilePath string, skip []string) []shared.Violation { hasFiles, err := scan(directoryPath) if err != nil { return nil } if !hasFiles { return nil } // log.Println("\nCloudFormation files found") var allViolations []shared.Violation var taggable map[string]bool if specFilePath != "" { var loadSpecErr error taggable, loadSpecErr = loadTaggableResourcesFromSpec(specFilePath) if loadSpecErr != nil { log.Printf("Warning: Could not load or parse --cfn-spec file '%s': %v.", specFilePath, loadSpecErr) taggable = nil } else { // log.Println("Parsing CloudFormation spec file.") } } walkErr := filepath.Walk(directoryPath, func(path string, info os.FileInfo, walkErr error) error { if walkErr != nil { log.Printf("Error accessing %q: %v\n", path, walkErr) return walkErr } for _, skipped := range skip { if strings.HasPrefix(path, skipped) { if info.IsDir() { return filepath.SkipDir } return nil } } if info.IsDir() { dirName := info.Name() if slices.Contains(config.SkippedDirs, dirName) { return filepath.SkipDir } } if !info.IsDir() && (filepath.Ext(path) == ".yaml" || filepath.Ext(path) == ".yml" || filepath.Ext(path) == ".json") { violations, processErr := processFile(path, requiredTags, caseInsensitive, taggable) if processErr != nil { log.Printf("Error processing file %s: %v\n", path, processErr) return nil // Example: Continue walking } allViolations = append(allViolations, violations...) } return nil }) if walkErr != nil { log.Printf("Error scanning directory %s: %v\n", directoryPath, walkErr) } return allViolations } // processFile parses files and maps the cfn nodes func processFile(filePath string, requiredTags shared.TagMap, caseInsensitive bool, taggable map[string]bool) ([]shared.Violation, error) { data, err := os.ReadFile(filePath) if err != nil { log.Printf("Error reading %s: %v\n", filePath, err) return nil, fmt.Errorf("reading file %s: %w", filePath, err) } content := string(data) lines := strings.Split(content, "\n") skipAll := strings.Contains(content, config.TagNagIgnoreAll) root, err := parseYAML(filePath) if err != nil { return nil, fmt.Errorf("parsing file %s: %w", filePath, err) } // search root node for resources node resourcesMapping := mapNodes(findMapNode(root, "Resources")) if resourcesMapping == nil { log.Printf("No 'Resources' section found in %s\n", filePath) return []shared.Violation{}, nil } violations := checkResourcesForTags(resourcesMapping, requiredTags, caseInsensitive, lines, skipAll, taggable, filePath) return violations, nil } ================================================ FILE: internal/cloudformation/resources.go ================================================ package cloudformation import ( "fmt" "log" "strings" "github.com/jakebark/tag-nag/internal/shared" "gopkg.in/yaml.v3" ) // getResourceViolations inspects resource blocks and returns violations func checkResourcesForTags(resourcesMapping map[string]*yaml.Node, requiredTags shared.TagMap, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool, filePath string) []shared.Violation { var violations []shared.Violation for resourceName, resourceNode := range resourcesMapping { // resourceNode == yaml node for resource resourceMapping := mapNodes(resourceNode) typeNode, ok := resourceMapping["Type"] if !ok || !strings.HasPrefix(typeNode.Value, "AWS::") { continue } resourceType := typeNode.Value if taggable != nil { isTaggable, found := taggable[resourceType] if found && !isTaggable { continue } } properties := make(map[string]any) // tags are part of the properties node if propsNode, ok := resourceMapping["Properties"]; ok { _ = propsNode.Decode(&properties) } tags, err := extractTagMap(properties, caseInsensitive) if err != nil { log.Printf("Error extracting tags from resource %s: %v\n", resourceName, err) continue } missing := shared.FilterMissingTags(requiredTags, tags, caseInsensitive) if len(missing) > 0 { violation := shared.Violation{ ResourceName: resourceName, ResourceType: resourceType, Line: resourceNode.Line, MissingTags: missing, FilePath: filePath, } // if file-level or resource-level ignore is found if skipAll || skipResource(resourceNode, fileLines) { violation.Skip = true } violations = append(violations, violation) } } return violations } // extractTagMap extracts a yaml/json map to a go map func extractTagMap(properties map[string]any, caseInsensitive bool) (shared.TagMap, error) { tagsMap := make(shared.TagMap) literalTags, exists := properties["Tags"] if !exists { return tagsMap, nil } tagsList, ok := literalTags.([]any) if !ok { return tagsMap, fmt.Errorf("Tags format is invalid") // tags are not in a list } for _, tagInterface := range tagsList { tagEntry, ok := tagInterface.(map[string]any) if !ok { continue } key, ok := tagEntry["Key"].(string) if !ok { continue } var tagValue string if valStr, ok := tagEntry["Value"].(string); ok { tagValue = valStr } else if refMap, ok := tagEntry["Value"].(map[string]any); ok { if ref, exists := refMap["Ref"]; exists { if refStr, ok := ref.(string); ok { tagValue = fmt.Sprintf("!Ref %s", refStr) } } } key = shared.NormalizeCase(key, caseInsensitive) tagsMap[key] = []string{tagValue} } return tagsMap, nil } ================================================ FILE: internal/cloudformation/resources_test.go ================================================ package cloudformation import ( "testing" "github.com/google/go-cmp/cmp" "github.com/jakebark/tag-nag/internal/shared" ) func TestExtractTagMap(t *testing.T) { tests := []struct { name string properties map[string]any caseInsensitive bool expected shared.TagMap expectedErr bool }{ { name: "no tag key", properties: map[string]any{ "OtherKey": "Value"}, expected: shared.TagMap{}, }, { name: "empty list", properties: map[string]any{ "Tags": []any{}, }, expected: shared.TagMap{}, }, { name: "not a list", properties: map[string]any{ "Tags": map[string]string{"Key": "Value"}, }, expectedErr: true, }, { name: "literal tags", properties: map[string]any{ "Tags": []any{ map[string]any{"Key": "Owner", "Value": "Jake"}, map[string]any{"Key": "Env", "Value": "Dev"}, }, }, expected: shared.TagMap{ "Owner": []string{"Jake"}, "Env": []string{"Dev"}, }, }, // { // name: "referenced tags", // properties: map[string]any{ // "Tags": []any{ // map[string]any{"Key": "StackName", "Value": map[string]any{"Ref": "AWS::StackName"}}, // }, // }, // expected: shared.TagMap{ // "StackName": []string{"!Ref StackName"}, // }, // }, // { // name: "mixed tags, literal and referenced", // properties: map[string]any{ // "Tags": []any{ // map[string]any{"Key": "Owner", "Value": "Jake"}, // map[string]any{"Key": "StackName", "Value": map[string]any{"Ref": "AWS::StackName"}}, // }, // }, // expected: shared.TagMap{ // "Owner": []string{"Jake"}, // "StackName": []string{"!Ref StackName"}, // }, // }, { name: "literal tags, case insensitive", properties: map[string]any{ "Tags": []any{ map[string]any{"Key": "Owner", "Value": "Jake"}, map[string]any{"Key": "env", "Value": "Dev"}, }, }, caseInsensitive: true, expected: shared.TagMap{ "owner": []string{"Jake"}, "env": []string{"Dev"}, }, }, { name: "missing key", properties: map[string]any{ "Tags": []any{ map[string]any{"Value": "Jake"}, }, }, expected: shared.TagMap{}, }, { name: "missing value", properties: map[string]any{ "Tags": []any{ map[string]any{"Key": "OptionalTag"}, }, }, expected: shared.TagMap{ "OptionalTag": []string{""}, }, }, { name: "non-string", properties: map[string]any{ "Tags": []any{ map[string]any{"Key": 123, "Value": "Jake"}, }, }, expected: shared.TagMap{}, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := extractTagMap(tc.properties, tc.caseInsensitive) if (err != nil) != tc.expectedErr { t.Fatalf("extractTagMap() error = %v, expectedErr %v", err, tc.expectedErr) } if !tc.expectedErr { if diff := cmp.Diff(tc.expected, got); diff != "" { t.Errorf("extractTagMap() mismatch (-expected +got):\n%s", diff) } } }) } } ================================================ FILE: internal/cloudformation/scan.go ================================================ package cloudformation import ( "errors" "io/fs" "path/filepath" ) // scan looks for cfn files func scan(directoryPath string) (bool, error) { found := false targetExts := map[string]bool{ ".yaml": true, ".yml": true, ".json": true, } walkErr := filepath.WalkDir(directoryPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if !d.IsDir() { if targetExts[filepath.Ext(path)] { found = true return fs.ErrNotExist // stop scan immediately } } return nil }) if walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) { return false, walkErr } return found, nil } ================================================ FILE: internal/cloudformation/spec_loader.go ================================================ package cloudformation import ( "encoding/json" "fmt" "log" "os" "strings" ) type cfnSpec struct { ResourceTypes map[string]cfnResourceType `json:"ResourceTypes"` PropertyTypes map[string]cfnPropertyType `json:"PropertyTypes"` } type cfnResourceType struct { Properties map[string]cfnProperty `json:"Properties"` } type cfnProperty struct { Required bool `json:"Required"` Type string `json:"Type"` // list ItemType string `json:"ItemType"` // tag PrimitiveType string `json:"PrimitiveType"` PrimitiveItemType string `json:"PrimitiveItemType"` // Other fields ignored } type cfnPropertyType struct { Properties map[string]cfnProperty `json:"Properties"` } // isTaggable checks the cfn spec file to see if a resource can be tagged func (rt cfnResourceType) isTaggable(specData *cfnSpec) bool { tagsProp, ok := rt.Properties["Tags"] if !ok { return false } if tagsProp.Type == "List" && tagsProp.ItemType == "Tag" { tagTypeDef, tagTypeExists := specData.PropertyTypes["Tag"] if tagTypeExists { _, keyExists := tagTypeDef.Properties["Key"] _, valueExists := tagTypeDef.Properties["Value"] return keyExists && valueExists } } return false } // LoadTaggableResourcesFromSpec parses the provided spec file path func loadTaggableResourcesFromSpec(specFilePath string) (map[string]bool, error) { log.Printf("Attempting to load CloudFormation specification from: %s", specFilePath) data, err := os.ReadFile(specFilePath) if err != nil { return nil, fmt.Errorf("failed to read CloudFormation spec file '%s': %w", specFilePath, err) } var specData cfnSpec err = json.Unmarshal(data, &specData) if err != nil { return nil, fmt.Errorf("failed to parse CloudFormation spec JSON from '%s': %w", specFilePath, err) } if specData.ResourceTypes == nil { return nil, fmt.Errorf("invalid CloudFormation spec format: missing 'ResourceTypes' in '%s'", specFilePath) } if specData.PropertyTypes == nil { return nil, fmt.Errorf("invalid CloudFormation spec format: missing 'PropertyTypes' in '%s'", specFilePath) } taggableMap := make(map[string]bool) for resourceName, resourceDef := range specData.ResourceTypes { if strings.HasPrefix(resourceName, "AWS::") { taggableMap[resourceName] = resourceDef.isTaggable(&specData) } } log.Printf("Loaded %d AWS resource types.", len(taggableMap)) return taggableMap, nil } ================================================ FILE: internal/cloudformation/spec_loader_test.go ================================================ package cloudformation import ( "encoding/json" "testing" ) // helper to create a test spec file func createTestSpec() *cfnSpec { specJSON := `{ "PropertyTypes": { "Tag": { "Properties": { "Key": { "PrimitiveType": "String", "Required": true }, "Value": { "PrimitiveType": "String", "Required": true } } }, "OtherType": { "Properties": { "Name": { "PrimitiveType": "String" } } } }, "ResourceTypes": {} }` var spec cfnSpec if err := json.Unmarshal([]byte(specJSON), &spec); err != nil { panic("Failed to unmarshal base test spec data: " + err.Error()) } return &spec } func TestIsTaggable(t *testing.T) { baseSpec := createTestSpec() tests := []struct { name string resourceJSON string want bool }{ { name: "taggable", resourceJSON: `{ "Properties": { "Name": { "PrimitiveType": "String" }, "Tags": { "Type": "List", "ItemType": "Tag", "Required": false } } }`, want: true, }, { name: "not taggable", resourceJSON: `{ "Properties": { "Name": { "PrimitiveType": "String" } } }`, want: false, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { var resourceDef cfnResourceType if err := json.Unmarshal([]byte(tc.resourceJSON), &resourceDef); err != nil { t.Fatalf("Failed to unmarshal test resource JSON: %v", err) } if got := resourceDef.isTaggable(baseSpec); got != tc.want { t.Errorf("isTaggable() = %v, want %v", got, tc.want) } }) } } ================================================ FILE: internal/config/config.go ================================================ package config import ( "github.com/zclconf/go-cty/cty/function" "github.com/zclconf/go-cty/cty/function/stdlib" ) const ( TagNagIgnore = "#tag-nag ignore" TagNagIgnoreAll = "#tag-nag ignore-all" DefaultConfigFile = ".tag-nag.yml" AltConfigFile = ".tag-nag.yaml" ) var SkippedDirs = []string{ ".terraform", ".git", } // terraform functions, used when evaluating context of locals and vars // added manually, no reasonable workaround to auto-import all // https://developer.hashicorp.com/terraform/language/functions // https://pkg.go.dev/github.com/zclconf/go-cty@v1.16.2/cty/function/stdlib var StdlibFuncs = map[string]function.Function{ "abs": stdlib.AbsoluteFunc, "ceil": stdlib.CeilFunc, "chomp": stdlib.ChompFunc, "chunklist": stdlib.ChunklistFunc, "coalesce": stdlib.CoalesceFunc, "coalescelist": stdlib.CoalesceListFunc, "compact": stdlib.CompactFunc, "concat": stdlib.ConcatFunc, "contains": stdlib.ContainsFunc, "csvdecode": stdlib.CSVDecodeFunc, "distinct": stdlib.DistinctFunc, "element": stdlib.ElementFunc, "flatten": stdlib.FlattenFunc, "floor": stdlib.FloorFunc, "format": stdlib.FormatFunc, "formatdate": stdlib.FormatDateFunc, "formatlist": stdlib.FormatListFunc, "indent": stdlib.IndentFunc, "index": stdlib.IndexFunc, "int": stdlib.IntFunc, "join": stdlib.JoinFunc, "jsondecode": stdlib.JSONDecodeFunc, "jsonencode": stdlib.JSONEncodeFunc, "keys": stdlib.KeysFunc, "length": stdlib.LengthFunc, "log": stdlib.LogFunc, "lookup": stdlib.LookupFunc, "lower": stdlib.LowerFunc, "max": stdlib.MaxFunc, "merge": stdlib.MergeFunc, "min": stdlib.MinFunc, "parseint": stdlib.ParseIntFunc, "pow": stdlib.PowFunc, "range": stdlib.RangeFunc, "regex": stdlib.RegexFunc, "regexall": stdlib.RegexAllFunc, "regexreplace": stdlib.RegexReplaceFunc, "replace": stdlib.ReplaceFunc, "reverse": stdlib.ReverseFunc, "reverselist": stdlib.ReverseListFunc, "setunion": stdlib.SetUnionFunc, "slice": stdlib.SliceFunc, "sort": stdlib.SortFunc, "split": stdlib.SplitFunc, "trim": stdlib.TrimFunc, "trimprefix": stdlib.TrimPrefixFunc, "trimspace": stdlib.TrimSpaceFunc, "trimsuffix": stdlib.TrimSuffixFunc, "upper": stdlib.UpperFunc, "values": stdlib.ValuesFunc, } ================================================ FILE: internal/inputs/inputs.go ================================================ package inputs import ( "fmt" "log" "os" "strings" "github.com/jakebark/tag-nag/internal/shared" "github.com/spf13/pflag" ) type UserInput struct { Directory string RequiredTags shared.TagMap CaseInsensitive bool DryRun bool CfnSpecPath string Skip []string OutputFormat shared.OutputFormat } // ParseFlags returns pased CLI flags and arguments func ParseFlags() UserInput { var caseInsensitive bool var dryRun bool var tags string var cfnSpecPath string var skip string var outputFormat string pflag.BoolVarP(&caseInsensitive, "case-insensitive", "c", false, "Make tag checks non-case-sensitive") pflag.BoolVarP(&dryRun, "dry-run", "d", false, "Dry run tag:nag without triggering exit(1) code") pflag.StringVar(&tags, "tags", "", "Comma-separated list of required tag keys (e.g., 'Owner,Environment[Dev,Prod]')") pflag.StringVar(&cfnSpecPath, "cfn-spec", "", "Optional path to CloudFormationResourceSpecification.json)") pflag.StringVarP(&skip, "skip", "s", "", "Comma-separated list of files or directories to skip") pflag.StringVarP(&outputFormat, "output", "o", "text", "Output format: text, json, junit-xml, or sarif") pflag.Parse() if pflag.NArg() < 1 { log.Fatal("Error: specify a directory or file to scan") } // try config file if no tags provided if tags == "" { configFile, err := FindAndLoadConfigFile() if err != nil { log.Fatalf("Error loading config: %v", err) } if configFile != nil { // Use config output format if CLI wasn't specified configOutputFormat := shared.OutputFormat(outputFormat) outputFlag := pflag.Lookup("output") if !outputFlag.Changed && configFile.Settings.Output != "" { configOutputFormat = configFile.Settings.Output } return UserInput{ Directory: pflag.Arg(0), RequiredTags: configFile.convertToTagMap(), CaseInsensitive: configFile.Settings.CaseInsensitive, DryRun: configFile.Settings.DryRun, CfnSpecPath: configFile.Settings.CfnSpec, Skip: configFile.Skip, OutputFormat: configOutputFormat, } } log.Fatal("Error: specify required tags using --tags or create a .tag-nag.yml config file") } parsedTags, err := parseTags(tags) if err != nil { log.Fatalf("Error parsing tags: %v", err) } var skipPaths []string if skip != "" { skipPaths = strings.Split(skip, ",") for i := range skipPaths { skipPaths[i] = strings.TrimSpace(skipPaths[i]) } } // Try to load config file for output format default configFile, err := FindAndLoadConfigFile() if err != nil && !os.IsNotExist(err) { log.Fatalf("Error loading config: %v", err) } format := shared.OutputFormat(outputFormat) // Use config output format if CLI wasn't explicitly provided and config exists outputFlag := pflag.Lookup("output") if !outputFlag.Changed && configFile != nil && configFile.Settings.Output != "" { format = configFile.Settings.Output } if format != shared.OutputFormatText && format != shared.OutputFormatJSON && format != shared.OutputFormatJUnitXML && format != shared.OutputFormatSARIF { log.Fatalf("Invalid output format '%s'. Supported formats: text, json, junit-xml, sarif", outputFormat) } return UserInput{ Directory: pflag.Arg(0), RequiredTags: parsedTags, CaseInsensitive: caseInsensitive, DryRun: dryRun, CfnSpecPath: cfnSpecPath, Skip: skipPaths, OutputFormat: format, } } // parses tag input components func parseTags(input string) (shared.TagMap, error) { tagMap := make(shared.TagMap) pairs := splitTags(input) for _, pair := range pairs { trimmed := strings.TrimSpace(pair) if trimmed == "" { continue } key, values, err := parseTag(trimmed) if err != nil { return nil, fmt.Errorf("failed to parse tag component '%s': %w", trimmed, err) } tagMap[key] = values } return tagMap, nil } // parses tag keys and values func parseTag(tagComponent string) (key string, values []string, err error) { trimmed := strings.TrimSpace(tagComponent) if trimmed == "" { return "", nil, fmt.Errorf("empty tag component") } // key and value openBracketIdx := strings.Index(trimmed, "[") if openBracketIdx != -1 { if !strings.HasSuffix(trimmed, "]") { return "", nil, fmt.Errorf("invalid tag format: %s. Expected closing ']'", trimmed) } key = strings.TrimSpace(trimmed[:openBracketIdx]) if key == "" { return "", nil, fmt.Errorf("empty key in bracket format: %s", trimmed) } valuesStr := trimmed[openBracketIdx+1 : len(trimmed)-1] if valuesStr == "" { return key, []string{}, nil } valParts := strings.Split(valuesStr, ",") for _, v := range valParts { trimmedVal := strings.TrimSpace(v) if trimmedVal != "" { values = append(values, trimmedVal) } } return key, values, nil } // key only if strings.Contains(trimmed, "[") || strings.Contains(trimmed, "]") { return "", nil, fmt.Errorf("invalid tag format: %s. Contains '[' or ']' without matching pair or value definition", trimmed) } return trimmed, []string{}, nil } // splitTags splits the input string on commas outside of brackets // to fix the [a,b,c] issue func splitTags(input string) []string { var parts []string start := 0 depth := 0 for i, r := range input { switch r { case '[': depth++ case ']': if depth > 0 { depth-- } case ',': if depth == 0 { parts = append(parts, strings.TrimSpace(input[start:i])) start = i + 1 } } } parts = append(parts, strings.TrimSpace(input[start:])) return parts } ================================================ FILE: internal/inputs/inputs_test.go ================================================ package inputs import ( "reflect" "testing" "github.com/jakebark/tag-nag/internal/shared" ) func TestParseTags(t *testing.T) { testCases := []struct { name string input string expected shared.TagMap expectedError bool }{ { name: "key", input: "Owner", expected: shared.TagMap{ "Owner": {}, }, expectedError: false, }, { name: "multiple keys", input: "Owner, Environment , Project", expected: shared.TagMap{ "Owner": {}, "Environment": {}, "Project": {}, }, expectedError: false, }, { name: "mixed keys and values", input: "Owner[jake], Environment[Dev,Prod], CostCenter", expected: shared.TagMap{ "Owner": {"jake"}, "Environment": {"Dev", "Prod"}, "CostCenter": {}, }, expectedError: false, }, { name: "empty", input: "", expected: shared.TagMap{}, expectedError: false, }, { name: "whitespace", input: " , ", expected: shared.TagMap{}, expectedError: false, }, { name: "mixed keys and values, with whitespace", input: " Owner , Environment[Dev, Prod] ", expected: shared.TagMap{ "Owner": {}, "Environment": {"Dev", "Prod"}, }, expectedError: false, }, { name: "leading comma", input: ",Owner", expected: shared.TagMap{ "Owner": {}, }, expectedError: false, }, { name: "missing value", input: "Env[]", expected: shared.TagMap{ "Env": {}, // No values extracted }, expectedError: false, }, { name: "missing value, other values present", input: "Env[Dev,,Prod]", expected: shared.TagMap{ "Env": {"Dev", "Prod"}, }, expectedError: false, }, { name: "whitespace preserved", input: "Owner[it belongs to me]", expected: shared.TagMap{ "Owner": {"it belongs to me"}, }, expectedError: false, }, { name: "unclosed bracket", input: "invalid[value", expected: nil, expectedError: true, }, { name: "no key", input: "[value]", expected: nil, expectedError: true, }, { name: "stray bracket", input: "stray]", expected: nil, expectedError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { actual, err := parseTags(tc.input) if tc.expectedError { if err == nil { t.Errorf("parseTags(%q) expected an error, but got nil", tc.input) } return } if err != nil { t.Errorf("parseTags(%q) expected no error, but got: %v", tc.input, err) } if !reflect.DeepEqual(actual, tc.expected) { t.Errorf("parseTags(%q) = %v; want %v", tc.input, actual, tc.expected) } }) } } func TestSplitTags(t *testing.T) { testCases := []struct { name string input string expected []string }{ {"empty", "", []string{""}}, {"key", "Owner", []string{"Owner"}}, {"multiple keys", "Owner, Env , Project", []string{"Owner", "Env", "Project"}}, {"value", "Owner[Jake]", []string{"Owner[Jake]"}}, {"multiple values", "Env[Dev,Prod]", []string{"Env[Dev,Prod]"}}, {"mixed keys and values", "Owner[Jake], Env[Dev,Prod], CostCenter", []string{"Owner[Jake]", "Env[Dev,Prod]", "CostCenter"}}, {"trailing comma", "Owner,Env,", []string{"Owner", "Env", ""}}, {"leading comma", ",Owner,Env", []string{"", "Owner", "Env"}}, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { actual := splitTags(tc.input) if !reflect.DeepEqual(actual, tc.expected) { t.Errorf("splitTags(%q) = %v; want %v", tc.input, actual, tc.expected) } }) } } ================================================ FILE: internal/inputs/loader.go ================================================ package inputs import ( "fmt" "os" "github.com/jakebark/tag-nag/internal/config" "github.com/jakebark/tag-nag/internal/shared" "gopkg.in/yaml.v3" ) type Config struct { Tags []TagDefinition `yaml:"tags"` Settings Settings `yaml:"settings"` Skip []string `yaml:"skip"` } type TagDefinition struct { Key string `yaml:"key"` Values []string `yaml:"values,omitempty"` } type Settings struct { CaseInsensitive bool `yaml:"case_insensitive"` DryRun bool `yaml:"dry_run"` CfnSpec string `yaml:"cfn_spec"` Output shared.OutputFormat `yaml:"output"` } // FindAndLoadConfigFile attempts to find and load configuration file func FindAndLoadConfigFile() (*Config, error) { if _, err := os.Stat(config.DefaultConfigFile); err == nil { return processConfigFile(config.DefaultConfigFile) } if _, err := os.Stat(config.AltConfigFile); err == nil { return processConfigFile(config.AltConfigFile) } return nil, nil } // processConfigFile reads the config file func processConfigFile(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config file %s: %w", path, err) } var config Config if err := yaml.Unmarshal(data, &config); err != nil { return nil, fmt.Errorf("parsing config file %s: %w", path, err) } return &config, nil } // ConvertToTagMap converts config tags to internal TagMap format func (c *Config) convertToTagMap() shared.TagMap { tagMap := make(shared.TagMap) for _, tag := range c.Tags { tagMap[tag.Key] = tag.Values } return tagMap } ================================================ FILE: internal/inputs/loader_test.go ================================================ package inputs import ( "os" "reflect" "testing" "github.com/jakebark/tag-nag/internal/shared" ) func TestProcessConfigFile(t *testing.T) { testCases := []struct { name string configFile string expectedError bool expectedTags int expectedOwner bool expectedEnvValues []string expectedSettings Settings expectedSkips []string }{ { name: "tag keys", configFile: "../../testdata/config/tag_keys.yml", expectedError: false, expectedTags: 2, expectedOwner: true, expectedSettings: Settings{ CaseInsensitive: false, DryRun: false, CfnSpec: "", }, expectedSkips: []string{}, }, { name: "tag values", configFile: "../../testdata/config/tag_values.yml", expectedError: false, expectedTags: 3, expectedOwner: true, expectedEnvValues: []string{"Dev", "Test", "Prod"}, expectedSettings: Settings{ CaseInsensitive: false, DryRun: false, CfnSpec: "", }, expectedSkips: []string{}, }, { name: "full config", configFile: "../../testdata/config/full_config.yml", expectedError: false, expectedTags: 3, expectedOwner: true, expectedSettings: Settings{ CaseInsensitive: true, DryRun: false, CfnSpec: "/path/to/spec.json", }, expectedSkips: []string{"*.tmp", ".terraform", "test-data/**"}, }, { name: "empty settings", configFile: "../../testdata/config/empty_settings.yml", expectedError: false, expectedTags: 1, expectedOwner: true, expectedSettings: Settings{ CaseInsensitive: false, DryRun: false, CfnSpec: "", }, expectedSkips: []string{}, }, { name: "yaml extension", configFile: "../../testdata/config/yaml_extension.yaml", expectedError: false, expectedTags: 1, expectedOwner: true, expectedSettings: Settings{ CaseInsensitive: false, DryRun: false, CfnSpec: "", }, expectedSkips: []string{}, }, { name: "blank config", configFile: "../../testdata/config/blank_config.yml", expectedError: false, expectedTags: 0, expectedOwner: false, expectedSettings: Settings{ CaseInsensitive: false, DryRun: false, CfnSpec: "", }, expectedSkips: []string{}, }, { name: "invalid syntax", configFile: "../../testdata/config/invalid_syntax.yml", expectedError: true, }, { name: "missing tags", configFile: "../../testdata/config/missing_tags.yml", expectedError: false, expectedTags: 0, expectedOwner: false, expectedSettings: Settings{ CaseInsensitive: false, DryRun: true, CfnSpec: "", }, expectedSkips: []string{"*.tmp"}, }, { name: "invalid structure", configFile: "../../testdata/config/invalid_structure.yml", expectedError: true, }, { name: "invalid tag value array", configFile: "../../testdata/config/tag_array.yml", expectedError: true, }, { name: "no file", configFile: "../../testdata/config/does-not-exist.yml", expectedError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { config, err := processConfigFile(tc.configFile) // Check error expectation if tc.expectedError { if err == nil { t.Errorf("Expected error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } if config == nil { t.Errorf("Expected config but got nil") return } // Check number of tags if len(config.Tags) != tc.expectedTags { t.Errorf("Expected %d tags, got %d", tc.expectedTags, len(config.Tags)) } // Check if Owner tag exists hasOwner := false var envValues []string for _, tag := range config.Tags { if tag.Key == "Owner" { hasOwner = true } if tag.Key == "Environment" { envValues = tag.Values } } if hasOwner != tc.expectedOwner { t.Errorf("Expected Owner tag: %v, got: %v", tc.expectedOwner, hasOwner) } // Check Environment values if specified if tc.expectedEnvValues != nil { if !reflect.DeepEqual(envValues, tc.expectedEnvValues) { t.Errorf("Expected Environment values %v, got %v", tc.expectedEnvValues, envValues) } } // Check settings if config.Settings != tc.expectedSettings { t.Errorf("Expected settings %+v, got %+v", tc.expectedSettings, config.Settings) } // Check skip patterns (handle nil vs empty slice) configSkips := config.Skip if configSkips == nil { configSkips = []string{} } if !reflect.DeepEqual(configSkips, tc.expectedSkips) { t.Errorf("Expected skip patterns %v, got %v", tc.expectedSkips, configSkips) } }) } } func TestFindAndLoadConfigFile(t *testing.T) { // Save current directory originalDir, err := os.Getwd() if err != nil { t.Fatalf("Failed to get current directory: %v", err) } defer os.Chdir(originalDir) testCases := []struct { name string setupFunc func(t *testing.T, tempDir string) expectedError bool expectedConfig bool expectedTags int }{ { name: "no config files present", setupFunc: func(t *testing.T, tempDir string) { // Empty directory }, expectedError: false, expectedConfig: false, }, { name: ".tag-nag.yml present", setupFunc: func(t *testing.T, tempDir string) { content := "tags:\n - key: Owner\n" err := os.WriteFile(".tag-nag.yml", []byte(content), 0644) if err != nil { t.Fatalf("Failed to create test config: %v", err) } }, expectedError: false, expectedConfig: true, expectedTags: 1, }, { name: ".tag-nag.yaml present", setupFunc: func(t *testing.T, tempDir string) { content := "tags:\n - key: Project\n" err := os.WriteFile(".tag-nag.yaml", []byte(content), 0644) if err != nil { t.Fatalf("Failed to create test config: %v", err) } }, expectedError: false, expectedConfig: true, expectedTags: 1, }, { name: "both files present (should prefer .yml)", setupFunc: func(t *testing.T, tempDir string) { ymlContent := "tags:\n - key: Owner\n - key: Project\n" yamlContent := "tags:\n - key: Environment\n" err := os.WriteFile(".tag-nag.yml", []byte(ymlContent), 0644) if err != nil { t.Fatalf("Failed to create .yml config: %v", err) } err = os.WriteFile(".tag-nag.yaml", []byte(yamlContent), 0644) if err != nil { t.Fatalf("Failed to create .yaml config: %v", err) } }, expectedError: false, expectedConfig: true, expectedTags: 2, // Should use .yml file (2 tags), not .yaml file (1 tag) }, { name: "invalid config file", setupFunc: func(t *testing.T, tempDir string) { content := "tags:\n - key: Owner\n values: [invalid" err := os.WriteFile(".tag-nag.yml", []byte(content), 0644) if err != nil { t.Fatalf("Failed to create invalid config: %v", err) } }, expectedError: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Create temporary directory for each test tempDir, err := os.MkdirTemp("", "tag-nag-test") if err != nil { t.Fatalf("Failed to create temp dir: %v", err) } defer os.RemoveAll(tempDir) // Change to temp directory err = os.Chdir(tempDir) if err != nil { t.Fatalf("Failed to change to temp dir: %v", err) } // Setup test scenario tc.setupFunc(t, tempDir) // Test the function config, err := FindAndLoadConfigFile() // Check error expectation if tc.expectedError { if err == nil { t.Errorf("Expected error but got none") } return } if err != nil { t.Errorf("Unexpected error: %v", err) return } // Check config presence if tc.expectedConfig { if config == nil { t.Errorf("Expected config but got nil") return } if len(config.Tags) != tc.expectedTags { t.Errorf("Expected %d tags, got %d", tc.expectedTags, len(config.Tags)) } } else { if config != nil { t.Errorf("Expected no config but got: %+v", config) } } }) } } func TestConvertToTagMap(t *testing.T) { testCases := []struct { name string config Config expected shared.TagMap }{ { name: "empty config", config: Config{ Tags: []TagDefinition{}, }, expected: shared.TagMap{}, }, { name: "tags without values", config: Config{ Tags: []TagDefinition{ {Key: "Owner"}, {Key: "Project"}, }, }, expected: shared.TagMap{ "Owner": []string{}, "Project": []string{}, }, }, { name: "tags with values", config: Config{ Tags: []TagDefinition{ {Key: "Owner"}, {Key: "Environment", Values: []string{"Dev", "Test", "Prod"}}, {Key: "Project"}, }, }, expected: shared.TagMap{ "Owner": []string{}, "Environment": []string{"Dev", "Test", "Prod"}, "Project": []string{}, }, }, { name: "mixed values", config: Config{ Tags: []TagDefinition{ {Key: "Owner", Values: []string{"Alice", "Bob"}}, {Key: "Environment", Values: []string{"Prod"}}, {Key: "Project"}, }, }, expected: shared.TagMap{ "Owner": []string{"Alice", "Bob"}, "Environment": []string{"Prod"}, "Project": []string{}, }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { result := tc.config.convertToTagMap() // Check each key individually to handle nil vs empty slice differences if len(result) != len(tc.expected) { t.Errorf("Expected %d keys, got %d", len(tc.expected), len(result)) return } for key, expectedValues := range tc.expected { actualValues, exists := result[key] if !exists { t.Errorf("Expected key %s not found in result", key) continue } // Handle nil vs empty slice if actualValues == nil { actualValues = []string{} } if expectedValues == nil { expectedValues = []string{} } if !reflect.DeepEqual(actualValues, expectedValues) { t.Errorf("Key %s: expected %v, got %v", key, expectedValues, actualValues) } } }) } } ================================================ FILE: internal/output/formatter.go ================================================ package output import "github.com/jakebark/tag-nag/internal/shared" type Formatter interface { Format(violations []shared.Violation) ([]byte, error) } // GetFormatter returns the appropriate formatter for the given format func GetFormatter(format shared.OutputFormat) Formatter { switch format { case shared.OutputFormatJSON: return &JSONFormatter{} case shared.OutputFormatJUnitXML: return &JUnitXMLFormatter{} case shared.OutputFormatSARIF: return &SARIFFormatter{} case shared.OutputFormatText: fallthrough default: return &TextFormatter{} } } ================================================ FILE: internal/output/formatter_test.go ================================================ package output import ( "reflect" "testing" "github.com/jakebark/tag-nag/internal/shared" ) func TestGetFormatter(t *testing.T) { testCases := []struct { name string format shared.OutputFormat expectedType string }{ { name: "json format", format: shared.OutputFormatJSON, expectedType: "*output.JSONFormatter", }, { name: "junit-xml format", format: shared.OutputFormatJUnitXML, expectedType: "*output.JUnitXMLFormatter", }, { name: "sarif format", format: shared.OutputFormatSARIF, expectedType: "*output.SARIFFormatter", }, { name: "text format", format: shared.OutputFormatText, expectedType: "*output.TextFormatter", }, { name: "unknown format defaults to text", format: shared.OutputFormat("unknown"), expectedType: "*output.TextFormatter", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { formatter := GetFormatter(tc.format) actualType := reflect.TypeOf(formatter).String() if actualType != tc.expectedType { t.Errorf("GetFormatter(%q) = %s; want %s", tc.format, actualType, tc.expectedType) } }) } } ================================================ FILE: internal/output/json.go ================================================ package output import ( "encoding/json" "github.com/jakebark/tag-nag/internal/shared" ) // JSONFormatter implements JSON output format type JSONFormatter struct{} // JSONOutput represents the structured JSON output format type JSONOutput struct { Violations []shared.Violation `json:"violations"` Summary Summary `json:"summary"` } // Summary provides aggregate information about violations type Summary struct { Total int `json:"total"` Skipped int `json:"skipped"` FilesAffected int `json:"files_affected"` } // Format formats violations as JSON func (f *JSONFormatter) Format(violations []shared.Violation) ([]byte, error) { var skipped int files := make(map[string]bool) for _, v := range violations { if v.Skip { skipped++ } files[v.FilePath] = true } output := JSONOutput{ Violations: violations, Summary: Summary{ Total: len(violations), Skipped: skipped, FilesAffected: len(files), }, } return json.MarshalIndent(output, "", " ") } ================================================ FILE: internal/output/json_test.go ================================================ package output import ( "encoding/json" "testing" "github.com/jakebark/tag-nag/internal/shared" ) func TestJSONFormatter_Format(t *testing.T) { testCases := []struct { name string violations []shared.Violation wantJSON bool wantFields []string }{ { name: "empty violations", violations: []shared.Violation{}, wantJSON: true, wantFields: []string{"violations", "summary"}, }, { name: "single violation", violations: []shared.Violation{ { ResourceType: "aws_s3_bucket", ResourceName: "test", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 10, }, }, wantJSON: true, wantFields: []string{"violations", "summary"}, }, { name: "multiple violations", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}}, {ResourceType: "aws_instance", ResourceName: "test2", MissingTags: []string{"Env"}}, }, wantJSON: true, wantFields: []string{"violations", "summary"}, }, { name: "skipped violation", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test", Skip: true}, }, wantJSON: true, wantFields: []string{"violations", "summary"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { formatter := &JSONFormatter{} output, err := formatter.Format(tc.violations) if err != nil { t.Errorf("Format() error = %v", err) return } if tc.wantJSON { var parsed map[string]interface{} if err := json.Unmarshal(output, &parsed); err != nil { t.Errorf("Output is not valid JSON: %v", err) } for _, field := range tc.wantFields { if _, exists := parsed[field]; !exists { t.Errorf("Missing field %q in JSON output", field) } } } }) } } ================================================ FILE: internal/output/junit.go ================================================ package output import ( "encoding/xml" "fmt" "strings" "github.com/jakebark/tag-nag/internal/shared" ) type JUnitXMLFormatter struct{} type TestSuite struct { XMLName xml.Name `xml:"testsuite"` Name string `xml:"name,attr"` Tests int `xml:"tests,attr"` Failures int `xml:"failures,attr"` TestCases []TestCase `xml:"testcase"` } type TestCase struct { XMLName xml.Name `xml:"testcase"` Name string `xml:"name,attr"` ClassName string `xml:"classname,attr"` Failure *Failure `xml:"failure,omitempty"` } type Failure struct { XMLName xml.Name `xml:"failure"` Message string `xml:"message,attr"` } // Format formats violations as JUnit XML func (f *JUnitXMLFormatter) Format(violations []shared.Violation) ([]byte, error) { var testCases []TestCase failures := 0 for _, v := range violations { testCase := TestCase{ Name: fmt.Sprintf("%s.%s", v.ResourceType, v.ResourceName), ClassName: v.FilePath, } if !v.Skip { failures++ testCase.Failure = &Failure{ Message: fmt.Sprintf("Missing tags: %s", strings.Join(v.MissingTags, ", ")), } } testCases = append(testCases, testCase) } testSuite := TestSuite{ Name: "tag-nag", Tests: len(violations), Failures: failures, TestCases: testCases, } output, err := xml.MarshalIndent(testSuite, "", " ") if err != nil { return nil, err } return []byte(xml.Header + string(output)), nil } ================================================ FILE: internal/output/junit_test.go ================================================ package output import ( "encoding/xml" "strings" "testing" "github.com/jakebark/tag-nag/internal/shared" ) func TestJUnitXMLFormatter_Format(t *testing.T) { testCases := []struct { name string violations []shared.Violation wantXML bool wantTests int wantFailures int }{ { name: "empty violations", violations: []shared.Violation{}, wantXML: true, wantTests: 0, wantFailures: 0, }, { name: "single violation", violations: []shared.Violation{ { ResourceType: "aws_s3_bucket", ResourceName: "test", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 10, }, }, wantXML: true, wantTests: 1, wantFailures: 1, }, { name: "multiple violations", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}}, {ResourceType: "aws_instance", ResourceName: "test2", MissingTags: []string{"Env"}}, }, wantXML: true, wantTests: 2, wantFailures: 2, }, { name: "skipped violation", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test", Skip: true}, }, wantXML: true, wantTests: 1, wantFailures: 0, }, { name: "mixed violations", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}}, {ResourceType: "aws_instance", ResourceName: "test2", Skip: true}, }, wantXML: true, wantTests: 2, wantFailures: 1, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { formatter := &JUnitXMLFormatter{} output, err := formatter.Format(tc.violations) if err != nil { t.Errorf("Format() error = %v", err) return } if tc.wantXML { var testSuite TestSuite if err := xml.Unmarshal(output, &testSuite); err != nil { t.Errorf("Output is not valid XML: %v", err) } if testSuite.Tests != tc.wantTests { t.Errorf("Tests count = %d; want %d", testSuite.Tests, tc.wantTests) } if testSuite.Failures != tc.wantFailures { t.Errorf("Failures count = %d; want %d", testSuite.Failures, tc.wantFailures) } if !strings.Contains(string(output), " 0 { fmt.Print(string(formattedOutput)) } nonSkippedCount := 0 for _, v := range violations { if !v.Skip { nonSkippedCount++ } } if nonSkippedCount > 0 && dryRun { log.Printf("\033[32mFound %d tag violation(s)\033[0m\n", nonSkippedCount) os.Exit(0) } else if nonSkippedCount > 0 { log.Printf("\033[31mFound %d tag violation(s)\033[0m\n", nonSkippedCount) os.Exit(1) } else { log.Println("No tag violations found") os.Exit(0) } } ================================================ FILE: internal/output/sarif.go ================================================ package output import ( "encoding/json" "fmt" "strings" "github.com/jakebark/tag-nag/internal/shared" ) type SARIFFormatter struct{} type sarifOutput struct { Schema string `json:"$schema"` Version string `json:"version"` Runs []sarifRun `json:"runs"` } type sarifRun struct { Tool struct { Driver struct { Name string `json:"name"` InformationURI string `json:"informationUri"` } `json:"driver"` } `json:"tool"` Results []sarifResult `json:"results"` } type sarifResult struct { RuleID string `json:"ruleId"` Kind string `json:"kind"` Level string `json:"level,omitempty"` Message struct { Text string `json:"text"` } `json:"message"` Locations []sarifLocation `json:"locations"` } type sarifLocation struct { PhysicalLocation struct { ArtifactLocation struct { URI string `json:"uri"` } `json:"artifactLocation"` Region struct { StartLine int `json:"startLine"` } `json:"region"` } `json:"physicalLocation"` } // Format formats violations as SARIF v2.1.0 func (f *SARIFFormatter) Format(violations []shared.Violation) ([]byte, error) { var results []sarifResult for _, v := range violations { r := sarifResult{RuleID: "missing-tags"} r.Message.Text = fmt.Sprintf("%s %q is missing tags: %s", v.ResourceType, v.ResourceName, strings.Join(v.MissingTags, ", ")) if v.Skip { r.Kind = "notApplicable" } else { r.Kind = "fail" r.Level = "error" } loc := sarifLocation{} loc.PhysicalLocation.ArtifactLocation.URI = v.FilePath loc.PhysicalLocation.Region.StartLine = v.Line r.Locations = []sarifLocation{loc} results = append(results, r) } run := sarifRun{Results: results} run.Tool.Driver.Name = "tag-nag" run.Tool.Driver.InformationURI = "https://github.com/jakebark/tag-nag" output := sarifOutput{ Schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/main/sarif-2.1/schema/sarif-schema-2.1.0.json", Version: "2.1.0", Runs: []sarifRun{run}, } return json.MarshalIndent(output, "", " ") } ================================================ FILE: internal/output/sarif_test.go ================================================ package output import ( "encoding/json" "testing" "github.com/jakebark/tag-nag/internal/shared" ) func TestSARIFFormatter_Format(t *testing.T) { testCases := []struct { name string violations []shared.Violation wantResults int wantFailures int }{ { name: "empty violations", violations: []shared.Violation{}, wantResults: 0, wantFailures: 0, }, { name: "single violation", violations: []shared.Violation{ { ResourceType: "aws_s3_bucket", ResourceName: "test", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 10, }, }, wantResults: 1, wantFailures: 1, }, { name: "multiple violations", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 1}, {ResourceType: "aws_instance", ResourceName: "test2", MissingTags: []string{"Env"}, FilePath: "main.tf", Line: 20}, }, wantResults: 2, wantFailures: 2, }, { name: "skipped violation", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test", Skip: true, FilePath: "main.tf", Line: 1}, }, wantResults: 1, wantFailures: 0, }, { name: "mixed violations", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 1}, {ResourceType: "aws_instance", ResourceName: "test2", Skip: true, FilePath: "main.tf", Line: 10}, }, wantResults: 2, wantFailures: 1, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { formatter := &SARIFFormatter{} output, err := formatter.Format(tc.violations) if err != nil { t.Errorf("Format() error = %v", err) return } var parsed sarifOutput if err := json.Unmarshal(output, &parsed); err != nil { t.Errorf("Output is not valid JSON: %v", err) return } if parsed.Version != "2.1.0" { t.Errorf("Version = %q; want %q", parsed.Version, "2.1.0") } if len(parsed.Runs) != 1 { t.Fatalf("Runs count = %d; want 1", len(parsed.Runs)) } results := parsed.Runs[0].Results if len(results) != tc.wantResults { t.Errorf("Results count = %d; want %d", len(results), tc.wantResults) } failures := 0 for _, r := range results { if r.Kind == "fail" { failures++ } } if failures != tc.wantFailures { t.Errorf("Failure count = %d; want %d", failures, tc.wantFailures) } }) } } ================================================ FILE: internal/output/text.go ================================================ package output import ( "fmt" "strings" "github.com/jakebark/tag-nag/internal/shared" ) type TextFormatter struct{} // Format formats violations as human-readable text func (f *TextFormatter) Format(violations []shared.Violation) ([]byte, error) { var output strings.Builder fileGroups := make(map[string][]shared.Violation) for _, v := range violations { fileGroups[v.FilePath] = append(fileGroups[v.FilePath], v) } for filePath, fileViolations := range fileGroups { output.WriteString(fmt.Sprintf("\nViolation(s) in %s\n", filePath)) for _, v := range fileViolations { if v.Skip { output.WriteString(fmt.Sprintf(" %d: %s \"%s\" skipped\n", v.Line, v.ResourceType, v.ResourceName)) } else { output.WriteString(fmt.Sprintf(" %d: %s \"%s\" 🏷️ Missing tags: %s\n", v.Line, v.ResourceType, v.ResourceName, strings.Join(v.MissingTags, ", "))) } } } return []byte(output.String()), nil } ================================================ FILE: internal/output/text_test.go ================================================ package output import ( "strings" "testing" "github.com/jakebark/tag-nag/internal/shared" ) func TestTextFormatter_Format(t *testing.T) { testCases := []struct { name string violations []shared.Violation wantContains []string wantNotContains []string }{ { name: "empty violations", violations: []shared.Violation{}, wantContains: []string{}, }, { name: "single violation", violations: []shared.Violation{ { ResourceType: "aws_s3_bucket", ResourceName: "test", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 10, }, }, wantContains: []string{ "Violation(s) in main.tf", "10: aws_s3_bucket \"test\"", "Missing tags: Owner", }, }, { name: "multiple violations same file", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 5}, {ResourceType: "aws_instance", ResourceName: "test2", MissingTags: []string{"Env"}, FilePath: "main.tf", Line: 15}, }, wantContains: []string{ "Violation(s) in main.tf", "5: aws_s3_bucket \"test1\"", "15: aws_instance \"test2\"", "Missing tags: Owner", "Missing tags: Env", }, }, { name: "skipped violation", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test", FilePath: "main.tf", Line: 10, Skip: true}, }, wantContains: []string{ "Violation(s) in main.tf", "10: aws_s3_bucket \"test\" skipped", }, wantNotContains: []string{ "Missing tags:", }, }, { name: "mixed violations", violations: []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "test1", MissingTags: []string{"Owner"}, FilePath: "main.tf", Line: 5}, {ResourceType: "aws_instance", ResourceName: "test2", FilePath: "main.tf", Line: 15, Skip: true}, }, wantContains: []string{ "Violation(s) in main.tf", "5: aws_s3_bucket \"test1\"", "Missing tags: Owner", "15: aws_instance \"test2\" skipped", }, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { formatter := &TextFormatter{} output, err := formatter.Format(tc.violations) if err != nil { t.Errorf("Format() error = %v", err) return } outputStr := string(output) for _, want := range tc.wantContains { if !strings.Contains(outputStr, want) { t.Errorf("Output missing %q", want) } } for _, notWant := range tc.wantNotContains { if strings.Contains(outputStr, notWant) { t.Errorf("Output should not contain %q", notWant) } } }) } } ================================================ FILE: internal/shared/helpers.go ================================================ package shared import ( "fmt" "sort" "strings" ) // FilterMissingTags checks effectiveTags against requiredTags func FilterMissingTags(requiredTags TagMap, effectiveTags TagMap, caseInsensitive bool) []string { var missingTags []string for requiredKey, allowedValues := range requiredTags { effectiveValues, keyFound := matchTagKey(requiredKey, effectiveTags, caseInsensitive) // construct violation message violationMessage := requiredKey if len(allowedValues) > 0 { violationMessage = fmt.Sprintf("%s[%s]", requiredKey, strings.Join(allowedValues, ",")) } if !keyFound { missingTags = append(missingTags, violationMessage) continue } // if there are tag values required, check them if len(allowedValues) > 0 { if !matchTagValue(allowedValues, effectiveValues, caseInsensitive) { missingTags = append(missingTags, violationMessage) } } } sort.Strings(missingTags) // return missingTags } // matchTagKey checks required tag key against effective tags func matchTagKey(requiredKey string, effectiveTags TagMap, caseInsensitive bool) (values []string, found bool) { for effectiveKey, effectiveValues := range effectiveTags { if CompareCase(effectiveKey, requiredKey, caseInsensitive) { return effectiveValues, true } } return nil, false } // matchTagValue checks required tag values (if present) against effective tags func matchTagValue(allowedValues []string, effectiveValues []string, caseInsensitive bool) bool { if len(allowedValues) == 0 { // if no tag alues are required, return match return true } if len(effectiveValues) == 0 && len(allowedValues) > 0 { return false } for _, allowed := range allowedValues { for _, effectiveValue := range effectiveValues { if CompareCase(effectiveValue, allowed, caseInsensitive) { return true } } } return false } // NormalizeCase lowers the case if caseInsensitive is true func NormalizeCase(input string, caseInsensitive bool) string { if caseInsensitive { return strings.ToLower(input) } return input } // CompareCase compares case sensitivity where appropriate func CompareCase(first, second string, caseInsensitive bool) bool { if caseInsensitive { return strings.EqualFold(first, second) } return first == second } ================================================ FILE: internal/shared/helpers_test.go ================================================ package shared import ( "reflect" "sort" "testing" ) func TestFilterMissingTags(t *testing.T) { testCases := []struct { name string requiredTags TagMap effectiveTags TagMap caseInsensitive bool expectedMissing []string }{ { name: "tags present", requiredTags: TagMap{"Owner": {}, "Env": {"Prod", "Dev"}}, effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Prod"}}, caseInsensitive: false, expectedMissing: nil, }, { name: "missing key", requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, effectiveTags: TagMap{"Env": {"Prod"}}, caseInsensitive: false, expectedMissing: []string{"Owner"}, }, { name: "wrong value", requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Dev"}}, caseInsensitive: false, expectedMissing: []string{"Env[Prod]"}, }, { name: "missing key and value", requiredTags: TagMap{"Env": {"Prod"}}, effectiveTags: TagMap{"Owner": {"a"}}, caseInsensitive: false, expectedMissing: []string{"Env[Prod]"}, }, { name: "tags present, case insensitive", requiredTags: TagMap{"Owner": {}, "Env": {"Prod", "Dev"}}, effectiveTags: TagMap{"owner": {"a"}, "env": {"prod"}}, caseInsensitive: true, expectedMissing: nil, }, { name: "missing key, case insensitive", requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, effectiveTags: TagMap{"env": {"Prod"}}, caseInsensitive: true, expectedMissing: []string{"Owner"}, }, { name: "wrong value, case insensitive", requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, effectiveTags: TagMap{"owner": {"a"}, "env": {"Dev"}}, caseInsensitive: true, expectedMissing: []string{"Env[Prod]"}, }, { name: "missing key and value, case insensitive", requiredTags: TagMap{"Env": {"Prod"}}, effectiveTags: TagMap{"owner": {"a"}}, caseInsensitive: true, expectedMissing: []string{"Env[Prod]"}, }, { name: "no required tags", requiredTags: TagMap{}, effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Dev"}}, caseInsensitive: false, expectedMissing: nil, }, { name: "no tags", requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}}, effectiveTags: TagMap{}, caseInsensitive: false, expectedMissing: []string{"Owner", "Env[Prod]"}, }, { name: "multiple values required, one present", requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}}, effectiveTags: TagMap{"Region": {"us-west-2"}}, caseInsensitive: false, expectedMissing: nil, }, { name: "multiple values required, none present", requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}}, effectiveTags: TagMap{"Region": {"eu-central-1"}}, caseInsensitive: false, expectedMissing: []string{"Region[us-east-1,us-west-2]"}, }, { name: "multiple values required, one present, case insensitive", requiredTags: TagMap{"Env": {"Prod", "Dev"}}, effectiveTags: TagMap{"Env": {"prod"}}, caseInsensitive: true, expectedMissing: nil, }, { name: "multiple values required, none present, case insensitive", requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}}, effectiveTags: TagMap{"region": {"eu-central-1"}}, caseInsensitive: true, expectedMissing: []string{"Region[us-east-1,us-west-2]"}, }, { name: "multiple values required, multiple values present", requiredTags: TagMap{"Env": {"Prod", "Dev"}}, effectiveTags: TagMap{"Env": {"Prod", "Stage"}}, caseInsensitive: false, expectedMissing: nil, }, { name: "multiple values present, none match", requiredTags: TagMap{"Env": {"Prod"}}, effectiveTags: TagMap{"Env": {"Dev", "Stage"}}, caseInsensitive: false, expectedMissing: []string{"Env[Prod]"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { actual := FilterMissingTags(tc.requiredTags, tc.effectiveTags, tc.caseInsensitive) if actual != nil && tc.expectedMissing != nil { sort.Strings(actual) sort.Strings(tc.expectedMissing) } if !reflect.DeepEqual(actual, tc.expectedMissing) { t.Errorf("FilterMissingTags() = %#v; want %#v", actual, tc.expectedMissing) } }) } } func TestNormalizeCase(t *testing.T) { tests := []struct { name string input string caseInsensitive bool expected string }{ { name: "preserve case when false", input: "Owner", caseInsensitive: false, expected: "Owner", }, { name: "lowercase when true", input: "Owner", caseInsensitive: true, expected: "owner", }, { name: "already lowercase", input: "owner", caseInsensitive: true, expected: "owner", }, { name: "mixed case", input: "EnViRoNmEnT", caseInsensitive: true, expected: "environment", }, { name: "all uppercase", input: "ENVIRONMENT", caseInsensitive: true, expected: "environment", }, { name: "empty string case sensitive", input: "", caseInsensitive: false, expected: "", }, { name: "empty string case insensitive", input: "", caseInsensitive: true, expected: "", }, { name: "special characters preserved", input: "Tag-Name_123", caseInsensitive: true, expected: "tag-name_123", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := NormalizeCase(tc.input, tc.caseInsensitive) if actual != tc.expected { t.Errorf("NormalizeCase(%q, %v) = %q; want %q", tc.input, tc.caseInsensitive, actual, tc.expected) } }) } } func TestCompareCase(t *testing.T) { tests := []struct { name string first string second string caseInsensitive bool expected bool }{ { name: "exact match case sensitive", first: "Owner", second: "Owner", caseInsensitive: false, expected: true, }, { name: "case mismatch case sensitive", first: "Owner", second: "owner", caseInsensitive: false, expected: false, }, { name: "case mismatch case insensitive", first: "Owner", second: "owner", caseInsensitive: true, expected: true, }, { name: "different strings case sensitive", first: "Owner", second: "Environment", caseInsensitive: false, expected: false, }, { name: "different strings case insensitive", first: "Owner", second: "Environment", caseInsensitive: true, expected: false, }, { name: "mixed case match case insensitive", first: "EnViRoNmEnT", second: "environment", caseInsensitive: true, expected: true, }, { name: "both uppercase case insensitive", first: "ENVIRONMENT", second: "ENVIRONMENT", caseInsensitive: true, expected: true, }, { name: "empty strings", first: "", second: "", caseInsensitive: false, expected: true, }, { name: "empty vs non-empty", first: "", second: "Owner", caseInsensitive: true, expected: false, }, { name: "special characters case insensitive", first: "Tag-Name_123", second: "tag-name_123", caseInsensitive: true, expected: true, }, { name: "unicode case insensitive", first: "Café", second: "café", caseInsensitive: true, expected: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { actual := CompareCase(tc.first, tc.second, tc.caseInsensitive) if actual != tc.expected { t.Errorf("CompareCase(%q, %q, %v) = %v; want %v", tc.first, tc.second, tc.caseInsensitive, actual, tc.expected) } }) } } ================================================ FILE: internal/shared/types.go ================================================ package shared type TagMap map[string][]string type Violation struct { ResourceType string `json:"resource_type"` ResourceName string `json:"resource_name"` Line int `json:"line"` MissingTags []string `json:"missing_tags"` Skip bool `json:"skip"` FilePath string `json:"file_path"` } type OutputFormat string const ( OutputFormatText OutputFormat = "text" OutputFormatJSON OutputFormat = "json" OutputFormatJUnitXML OutputFormat = "junit-xml" OutputFormatSARIF OutputFormat = "sarif" ) ================================================ FILE: internal/terraform/default_tags.go ================================================ package terraform import ( "fmt" "log" "sort" "strings" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/jakebark/tag-nag/internal/shared" "github.com/zclconf/go-cty/cty" ) // processDefaultTags identifies the default tags func processDefaultTags(tfFiles []tfFile, tfContext *TerraformContext, caseInsensitive bool) DefaultTags { defaultTags := DefaultTags{ LiteralTags: make(map[string]shared.TagMap), } parser := hclparse.NewParser() for _, tf := range tfFiles { file, diags := parser.ParseHCLFile(tf.path) if diags.HasErrors() || file == nil { log.Printf("Error parsing %s during default tag scan: %v\n", tf.path, diags) continue } syntaxBody, ok := file.Body.(*hclsyntax.Body) if !ok { log.Printf("Failed to get syntax body for %s\n", tf.path) continue } processProviders(syntaxBody, &defaultTags, tfContext, caseInsensitive) } return defaultTags } // processProviders extracts any default_tags from providers func processProviders(body *hclsyntax.Body, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool) { for _, block := range body.Blocks { if block.Type == "provider" && len(block.Labels) > 0 { providerID := getProviderID(block, caseInsensitive) // handle ID tags := getDefaultTags(block, tfContext, caseInsensitive) // handle tags if len(tags) > 0 { var keys []string for key := range tags { keys = append(keys, key) // remove bool element of tag map } sort.Strings(keys) fmt.Printf("Found Terraform default tags for provider %s: [%v]\n", providerID, strings.Join(keys, ", ")) defaultTags.LiteralTags[providerID] = tags } } } } // getProviderID returns the provider identifier (aws or alias) func getProviderID(block *hclsyntax.Block, caseInsensitive bool) string { providerName := block.Labels[0] var alias string // check for alias presence if attr, ok := block.Body.Attributes["alias"]; ok { val, diags := attr.Expr.Value(nil) if !diags.HasErrors() { alias = val.AsString() } } return normalizeProviderID(providerName, alias, caseInsensitive) } // normalize ProviderID combines the provider name and alias ("aws.west"), aligning with resource provider naming func normalizeProviderID(providerName, alias string, caseInsensitive bool) string { providerID := providerName if alias != "" { providerID += "." + alias } providerID = shared.NormalizeCase(providerID, caseInsensitive) return providerID } // getDefaultTags returns the default_tags on a provider block. func getDefaultTags(block *hclsyntax.Block, tfContext *TerraformContext, caseInsensitive bool) shared.TagMap { // Add tfContext param for _, subBlock := range block.Body.Blocks { if subBlock.Type == "default_tags" { if tagsAttr, exists := subBlock.Body.Attributes["tags"]; exists { tagsVal, diags := tagsAttr.Expr.Value(tfContext.EvalContext) if diags.HasErrors() { log.Printf("Error evaluating default_tags expression for provider %v: %v", block.Labels, diags) return nil } if !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() { log.Printf("Warning: Evaluated default_tags for provider %v is not an object/map type, but %s. Skipping.", block.Labels, tagsVal.Type().FriendlyName()) return nil } if tagsVal.IsNull() { log.Printf("Warning: Evaluated default_tags for provider %v is null. Skipping.", block.Labels) return nil } evalTags := make(shared.TagMap) for key, val := range tagsVal.AsValueMap() { var valStr string if val.IsNull() { valStr = "" } else if val.Type() == cty.String { valStr = val.AsString() } else { strResult, err := convertCtyValueToString(val) if err != nil { log.Printf("Warning: Could not convert default tag value for key %q to string: %v. Using empty string.", key, err) valStr = "" } else { valStr = strResult } } effectiveKey := shared.NormalizeCase(key, caseInsensitive) evalTags[effectiveKey] = []string{valStr} } return evalTags } } } return nil // No default_tags block found } ================================================ FILE: internal/terraform/default_tags_test.go ================================================ package terraform import ( "testing" ) // Test for normalizeProviderID func TestNormalizeProviderID(t *testing.T) { tests := []struct { name string providerName string alias string caseInsensitive bool expected string }{ { name: "default", providerName: "aws", alias: "", expected: "aws", }, { name: "alias", providerName: "aws", alias: "west", expected: "aws.west", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := normalizeProviderID(tc.providerName, tc.alias, tc.caseInsensitive); got != tc.expected { t.Errorf("normalizeProviderID() = %v, expected %v", got, tc.expected) } }) } } ================================================ FILE: internal/terraform/helpers.go ================================================ package terraform import ( "encoding/json" "fmt" "log" "os/exec" "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/jakebark/tag-nag/internal/config" "github.com/jakebark/tag-nag/internal/shared" "github.com/zclconf/go-cty/cty" ctyjson "github.com/zclconf/go-cty/cty/json" ) // traversalToString converts a hcl hierachical/traversal string to a literal string func traversalToString(expr hcl.Expression, caseInsensitive bool) string { if ste, ok := expr.(*hclsyntax.ScopeTraversalExpr); ok { tokens := []string{} for _, step := range ste.Traversal { switch t := step.(type) { case hcl.TraverseRoot: tokens = append(tokens, t.Name) case hcl.TraverseAttr: tokens = append(tokens, t.Name) } } result := shared.NormalizeCase(strings.Join(tokens, "."), caseInsensitive) return result } // fallback - attempt to evaluate the expression as a literal value if v, diags := expr.Value(nil); !diags.HasErrors() { if v.Type().Equals(cty.String) { s := shared.NormalizeCase(v.AsString(), caseInsensitive) return s } else { return fmt.Sprintf("%v", v) } } return "" } // mergeTags combines multiple tag maps func mergeTags(tagMaps ...shared.TagMap) shared.TagMap { merged := make(shared.TagMap) for _, m := range tagMaps { for k, v := range m { merged[k] = v } } return merged } // SkipResource determines if a resource block should be skipped func SkipResource(block *hclsyntax.Block, lines []string) bool { index := block.DefRange().Start.Line if index < len(lines) { if strings.Contains(lines[index], config.TagNagIgnore) { return true } } return false } func convertCtyValueToString(val cty.Value) (string, error) { if !val.IsKnown() { return "", fmt.Errorf("value is unknown") } if val.IsNull() { return "", nil } ty := val.Type() switch { case ty == cty.String: return val.AsString(), nil case ty == cty.Number: bf := val.AsBigFloat() return bf.Text('f', -1), nil case ty == cty.Bool: return fmt.Sprintf("%t", val.True()), nil case ty.IsListType() || ty.IsTupleType() || ty.IsSetType() || ty.IsMapType() || ty.IsObjectType(): simpleJSON, err := ctyjson.SimpleJSONValue{Value: val}.MarshalJSON() if err != nil { return "", fmt.Errorf("failed to marshal complex type to json: %w", err) } strJSON := string(simpleJSON) if len(strJSON) >= 2 && strJSON[0] == '"' && strJSON[len(strJSON)-1] == '"' { var unquotedStr string if err := json.Unmarshal(simpleJSON, &unquotedStr); err == nil { return unquotedStr, nil } } return strJSON, nil default: return fmt.Sprintf("%v", val), nil // Best effort } } // loadTaggableResources calls the Terraform JSON schema and returns a set of all resources that are taggable func loadTaggableResources(providerAddr string) map[string]bool { out, err := exec.Command( "terraform", "providers", "schema", "-json", ).Output() if err != nil { // log.Printf("Failed to load AWS terraform provider schema: %v", err) return nil } // unmarshall what we need var s struct { ProviderSchemas map[string]struct { ResourceSchemas map[string]struct { Block struct { Attributes map[string]json.RawMessage `json:"attributes"` } `json:"block"` } `json:"resource_schemas"` } `json:"provider_schemas"` } if err := json.Unmarshal(out, &s); err != nil { log.Fatalf("failed to parse schema JSON: %v", err) return nil } taggable := make(map[string]bool) if ps, ok := s.ProviderSchemas[providerAddr]; ok { for resType, schema := range ps.ResourceSchemas { if _, has := schema.Block.Attributes["tags"]; has { taggable[resType] = true } else { // taggable[resType] = false } } } else { return nil } return taggable } ================================================ FILE: internal/terraform/helpers_test.go ================================================ package terraform import ( "reflect" "testing" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/jakebark/tag-nag/internal/shared" "github.com/zclconf/go-cty/cty" ) func TestTraversalToString(t *testing.T) { testCases := []struct { name string hclInput string caseInsensitive bool expected string }{ { name: "literal", hclInput: "Owner", caseInsensitive: false, expected: "Owner", }, { name: "literal, case insensitive", hclInput: "Owner", caseInsensitive: true, expected: "owner", }, { name: "traversal", hclInput: "local.network.subnets[0].id", caseInsensitive: false, expected: "local.network.subnets.id", }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { // Parse the HCL expression string expr, diags := hclsyntax.ParseExpression([]byte(tc.hclInput), tc.name+".tf", hcl.Pos{Line: 1, Column: 1}) if diags.HasErrors() { t.Fatalf("Failed to parse expression %q: %v", tc.hclInput, diags) } actual := traversalToString(expr, tc.caseInsensitive) if actual != tc.expected { t.Errorf("traversalToString(%q, %v) = %q; want %q", tc.hclInput, tc.caseInsensitive, actual, tc.expected) } }) } } func TestMergeTags(t *testing.T) { testCases := []struct { name string inputs []shared.TagMap expected shared.TagMap }{ { name: "empty", inputs: []shared.TagMap{}, expected: shared.TagMap{}, }, { name: "key", inputs: []shared.TagMap{ {"Environment": {}}, }, expected: shared.TagMap{"Environment": {}}, }, { name: "key and value", inputs: []shared.TagMap{{"Environment": {"Dev"}}}, expected: shared.TagMap{"Environment": {"Dev"}}, }, { name: "multiple keys and values", inputs: []shared.TagMap{ {"Environment": {"Dev"}}, {"Owner": {"Prod"}}, }, expected: shared.TagMap{"Environment": {"Dev"}, "Owner": {"Prod"}}, }, { name: "overlapping values, last wins", inputs: []shared.TagMap{ {"Environment": {"Dev"}, "Owner": {"jakebark"}}, {"Owner": {"Jake"}, "CostCenter": {"C-01"}}, }, expected: shared.TagMap{"Environment": {"Dev"}, "Owner": {"Jake"}, "CostCenter": {"C-01"}}, }, { name: "overlapping empty value, last wins", inputs: []shared.TagMap{ {"Environment": {"Dev"}}, {"Environment": {}}, }, expected: shared.TagMap{"Environment": {}}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { actual := mergeTags(tc.inputs...) if !reflect.DeepEqual(actual, tc.expected) { t.Errorf("mergeTags() = %v; want %v", actual, tc.expected) } }) } } func TestConvertCtyValueToString(t *testing.T) { tests := []struct { name string input cty.Value want string wantErr bool }{ { name: "string", input: cty.StringVal("hello world"), want: "hello world", }, { name: "empty string", input: cty.StringVal(""), want: "", }, { name: "number", input: cty.NumberIntVal(123), want: "123", }, { name: "float", input: cty.NumberFloatVal(123.45), want: "123.45", }, { name: "bool, true", input: cty.True, want: "true", }, { name: "bool, false", input: cty.False, want: "false", }, { name: "null", input: cty.NullVal(cty.String), want: "", }, { name: "unknown value", input: cty.UnknownVal(cty.String), wantErr: true, }, { name: "list", input: cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}), want: `["a","b"]`, }, { name: "map", input: cty.MapVal(map[string]cty.Value{"key": cty.StringVal("value")}), want: `{"key":"value"}`, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got, err := convertCtyValueToString(tc.input) if (err != nil) != tc.wantErr { t.Fatalf("convertCtyValueToString() error = %v, wantErr %v", err, tc.wantErr) } if !tc.wantErr && got != tc.want { t.Errorf("convertCtyValueToString() = %v, want %v", got, tc.want) } }) } } ================================================ FILE: internal/terraform/process.go ================================================ package terraform import ( "log" "os" "path/filepath" "slices" "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/jakebark/tag-nag/internal/config" "github.com/jakebark/tag-nag/internal/shared" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" ) type tfFile struct { path string info os.FileInfo } // ProcessDirectory walks all terraform files in directory func ProcessDirectory(directoryPath string, requiredTags map[string][]string, caseInsensitive bool, skip []string) []shared.Violation { hasFiles, err := scan(directoryPath) if err != nil { return nil } if !hasFiles { return nil } // log.Println("Terraform files found\n") var allViolations []shared.Violation taggable := loadTaggableResources("registry.terraform.io/hashicorp/aws") if taggable == nil { // log.Printf("Warning: Failed to load Terraform AWS Provider\nRun 'terraform init' to fix\n") // log.Printf("Continuing with limited features ... \n ") } tfContext, err := buildTagContext(directoryPath) if err != nil { tfContext = &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value), Functions: make(map[string]function.Function)}} } // single directory walk tfFiles, err := collectFiles(directoryPath, skip) if err != nil { log.Printf("Error scanning directory %q: %v\n", directoryPath, err) return nil } if len(tfFiles) == 0 { return nil } // extract default tags from all files defaultTags := processDefaultTags(tfFiles, tfContext, caseInsensitive) // process resources for tag violations for _, tf := range tfFiles { violations := processFile(tf.path, requiredTags, &defaultTags, tfContext, caseInsensitive, taggable) allViolations = append(allViolations, violations...) } return allViolations } // collectFiles identifies all elligible terraform files func collectFiles(directoryPath string, skip []string) ([]tfFile, error) { var tfFiles []tfFile err := filepath.Walk(directoryPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if skipDirectories(path, info, skip) { if info.IsDir() { return filepath.SkipDir } return nil } if !info.IsDir() && filepath.Ext(path) == ".tf" { tfFiles = append(tfFiles, tfFile{path: path, info: info}) } return nil }) return tfFiles, err } // skipDir identifies directories to ignore func skipDirectories(path string, info os.FileInfo, skip []string) bool { // user-defined skip paths for _, skipped := range skip { if strings.HasPrefix(path, skipped) { return true } } // default skipped directories eg .git if info.IsDir() { dirName := info.Name() if slices.Contains(config.SkippedDirs, dirName) { return true } } return false } // processFile parses files looking for resources func processFile(filePath string, requiredTags shared.TagMap, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool, taggable map[string]bool) []shared.Violation { data, err := os.ReadFile(filePath) if err != nil { log.Printf("Error reading %s: %v\n", filePath, err) return nil } content := string(data) lines := strings.Split(content, "\n") skipAll := strings.Contains(content, config.TagNagIgnoreAll) parser := hclparse.NewParser() file, diagnostics := parser.ParseHCLFile(filePath) if diagnostics.HasErrors() { log.Printf("Error parsing %s: %v\n", filePath, diagnostics) return nil } syntaxBody, ok := file.Body.(*hclsyntax.Body) if !ok { log.Printf("Parsing failed for %s\n", filePath) return nil } violations := checkResourcesForTags(syntaxBody, requiredTags, defaultTags, tfContext, caseInsensitive, lines, skipAll, taggable, filePath) return violations } ================================================ FILE: internal/terraform/references.go ================================================ package terraform import ( "fmt" "log" "os" "path/filepath" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/jakebark/tag-nag/internal/config" "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" ) func buildTagContext(directoryPath string) (*TerraformContext, error) { parsedFiles := make(map[string]*hcl.File) parser := hclparse.NewParser() // first pass, parse files err := filepath.Walk(directoryPath, func(path string, info os.FileInfo, err error) error { if err != nil { return err } if info.IsDir() { dirName := info.Name() for _, skipped := range config.SkippedDirs { if dirName == skipped { return filepath.SkipDir } } } if !info.IsDir() && filepath.Ext(path) == ".tf" { file, diags := parser.ParseHCLFile(path) if diags.HasErrors() { log.Printf("Error parsing HCL file %s: %v\n", path, diags) } if file != nil { parsedFiles[path] = file } } return nil }) if err != nil { return nil, fmt.Errorf("error walking directory %s: %w", directoryPath, err) } if len(parsedFiles) == 0 { log.Println("No Terraform files (.tf) found to build context.") return &TerraformContext{ EvalContext: &hcl.EvalContext{ Variables: make(map[string]cty.Value), Functions: make(map[string]function.Function), }, }, nil } // second pass, evaluate vars tfVars := make(map[string]cty.Value) for _, file := range parsedFiles { body, ok := file.Body.(*hclsyntax.Body) if !ok { continue } for _, block := range body.Blocks { if block.Type == "variable" && len(block.Labels) > 0 { varName := block.Labels[0] if defaultAttr, exists := block.Body.Attributes["default"]; exists { val, diags := defaultAttr.Expr.Value(nil) if diags.HasErrors() { log.Printf("Error evaluating default for variable %q: %v", varName, diags) val = cty.NullVal(cty.DynamicPseudoType) } tfVars[varName] = val } else { tfVars[varName] = cty.NullVal(cty.DynamicPseudoType) } } } } // 3rd pass, evaluate locals tfLocals := make(map[string]cty.Value) localsDefs := make(map[string]hcl.Expression) for _, file := range parsedFiles { body, ok := file.Body.(*hclsyntax.Body) if !ok { continue } for _, block := range body.Blocks { if block.Type == "locals" { for name, attr := range block.Body.Attributes { localsDefs[name] = attr.Expr } } } } evalCtxForLocals := &hcl.EvalContext{ Variables: map[string]cty.Value{"var": cty.ObjectVal(tfVars)}, Functions: config.StdlibFuncs, } evalCtxForLocals.Variables["local"] = cty.NullVal(cty.DynamicPseudoType) // Placeholder for local const maxLocalPasses = 10 evaluatedCount := 0 for pass := 0; pass < maxLocalPasses && evaluatedCount < len(localsDefs); pass++ { madeProgress := false evalCtxForLocals.Variables["local"] = cty.ObjectVal(tfLocals) for name, expr := range localsDefs { if _, exists := tfLocals[name]; exists { continue } val, diags := expr.Value(evalCtxForLocals) if !diags.HasErrors() { tfLocals[name] = val evaluatedCount++ madeProgress = true } } if !madeProgress && evaluatedCount < len(localsDefs) { log.Printf("Warning: Could not resolve all locals dependencies after %d passes.", pass+1) break } } finalCtx := &hcl.EvalContext{ Variables: map[string]cty.Value{ "var": cty.ObjectVal(tfVars), "local": cty.ObjectVal(tfLocals), }, Functions: config.StdlibFuncs, } return &TerraformContext{EvalContext: finalCtx}, nil } ================================================ FILE: internal/terraform/resources.go ================================================ package terraform import ( "log" "strings" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/jakebark/tag-nag/internal/shared" "github.com/zclconf/go-cty/cty" ) // checkResourcesForTags inspects resource blocks and returns violations func checkResourcesForTags(body *hclsyntax.Body, requiredTags shared.TagMap, defaultTags *DefaultTags, tfContext *TerraformContext, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool, filePath string) []shared.Violation { var violations []shared.Violation for _, block := range body.Blocks { if block.Type != "resource" || len(block.Labels) < 2 { // skip anything without 2 labels eg "aws_s3_bucket" and "this" continue } resourceType := block.Labels[0] // aws_s3_bucket resourceName := block.Labels[1] // this if !strings.HasPrefix(resourceType, "aws_") { continue } isTaggable := true // assume resource is taggable, by default if taggable != nil { var found bool isTaggable, found = taggable[resourceType] if !found { isTaggable = true // if not found, assume resource is taggable // isTaggable = false // log.Printf("Warning: Resource type %s not found in provider schema. Assuming not taggable.", resourceType) //todo } } else { } if !isTaggable { // log.Printf("Skipping non-taggable resource type: %s", resourceType) continue } providerID := getResourceProvider(block, caseInsensitive) providerEvalTags := defaultTags.LiteralTags[providerID] if providerEvalTags == nil { providerEvalTags = make(shared.TagMap) } resourceEvalTags := findTags(block, tfContext, caseInsensitive) effectiveTags := mergeTags(providerEvalTags, resourceEvalTags) missingTags := shared.FilterMissingTags(requiredTags, effectiveTags, caseInsensitive) if len(missingTags) > 0 { violation := shared.Violation{ ResourceType: resourceType, ResourceName: resourceName, Line: block.DefRange().Start.Line, MissingTags: missingTags, FilePath: filePath, } if skipAll || SkipResource(block, fileLines) { violation.Skip = true } violations = append(violations, violation) } } return violations } // getResourceProvider determines the provider for a resource block func getResourceProvider(block *hclsyntax.Block, caseInsensitive bool) string { if attr, ok := block.Body.Attributes["provider"]; ok { // provider is a literal string ("aws") val, diags := attr.Expr.Value(nil) if !diags.HasErrors() { s := shared.NormalizeCase(val.AsString(), caseInsensitive) return s } // provider is not a literal string ("aws.west") s := traversalToString(attr.Expr, caseInsensitive) if s != "" { return s } } // no explicit provider, return default provider defaultProvider := shared.NormalizeCase("aws", caseInsensitive) return defaultProvider } // findTags returns tag map from a resource block (with extractTags), if it has tags func findTags(block *hclsyntax.Block, tfContext *TerraformContext, caseInsensitive bool) shared.TagMap { evalTags := make(shared.TagMap) if attr, exists := block.Body.Attributes["tags"]; exists { childCtx := tfContext.EvalContext.NewChild() if childCtx.Variables == nil { childCtx.Variables = make(map[string]cty.Value) } childCtx.Variables["each"] = cty.ObjectVal(map[string]cty.Value{ "key": cty.StringVal(""), "value": cty.StringVal(""), }) tagsVal, diags := attr.Expr.Value(childCtx) if diags.HasErrors() { log.Printf("Error evaluating tags for resource %s.%s: %v", block.Labels[0], block.Labels[1], diags) return evalTags } if !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() { return evalTags } if tagsVal.IsNull() { return evalTags } for key, val := range tagsVal.AsValueMap() { var valStr string if val.IsNull() { valStr = "" } else if val.Type() == cty.String { valStr = val.AsString() } else { strResult, err := convertCtyValueToString(val) if err != nil { valStr = "" } else { valStr = strResult } } effectiveKey := shared.NormalizeCase(key, caseInsensitive) evalTags[effectiveKey] = []string{valStr} } } return evalTags } ================================================ FILE: internal/terraform/resources_test.go ================================================ package terraform import ( "sort" "strings" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/hclparse" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/jakebark/tag-nag/internal/shared" "github.com/zclconf/go-cty/cty" ) func TestCheckResourcesForTags_Taggability(t *testing.T) { parser := hclparse.NewParser() tfCode := ` resource "aws_s3_bucket" "taggable_bucket" { tags = { Owner = "test-user" // Missing Environment } } resource "aws_kms_alias" "non_taggable_alias" { alias_name = "alias/my-key-alias" target_key_id = "some-key-id" } resource "aws_instance" "another_taggable" { ami = "ami-12345" instance_type = "t2.micro" tags = { Owner = "test-user" Environment = "dev" } } resource "aws_route53_zone" "unknown_in_schema_should_be_checked" { name = "example.com" # Missing all tags } ` file, diags := parser.ParseHCL([]byte(tfCode), "test.tf") if diags.HasErrors() { t.Fatalf("Failed to parse test HCL: %v", diags) } body, ok := file.Body.(*hclsyntax.Body) if !ok { t.Fatalf("Could not get HCL syntax body") } requiredTags := shared.TagMap{ "Owner": {}, "Environment": {}, } lines := strings.Split(tfCode, "\n") t.Run("With Taggability Filter", func(t *testing.T) { taggableMap := map[string]bool{ "aws_s3_bucket": true, "aws_kms_alias": false, "aws_instance": true, "aws_iam_user": false, // Example of another non-taggable not in the HCL "aws_route53_zone": true, // Explicitly mark as taggable for test } mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}} mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)} violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, "test.tf") expectedViolations := []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "taggable_bucket", Line: 2, MissingTags: []string{"Environment"}, FilePath: "test.tf"}, {ResourceType: "aws_route53_zone", ResourceName: "unknown_in_schema_should_be_checked", Line: 22, MissingTags: []string{"Environment", "Owner"}, FilePath: "test.tf"}, // Order might vary } sortViolations(violations) sortViolations(expectedViolations) // Sort missing tags within each violation for stable comparison if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != "" { t.Errorf("checkResourcesForTags with filter mismatch (-want +got):\n%s", diff) } }) t.Run("Without Taggability Filter (nil map)", func(t *testing.T) { taggableMap := map[string]bool(nil) mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}} mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)} violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, "test.tf") expectedViolations := []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "taggable_bucket", Line: 2, MissingTags: []string{"Environment"}, FilePath: "test.tf"}, {ResourceType: "aws_kms_alias", ResourceName: "non_taggable_alias", Line: 8, MissingTags: []string{"Environment", "Owner"}, FilePath: "test.tf"}, {ResourceType: "aws_route53_zone", ResourceName: "unknown_in_schema_should_be_checked", Line: 22, MissingTags: []string{"Environment", "Owner"}, FilePath: "test.tf"}, } sortViolations(violations) sortViolations(expectedViolations) if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != "" { t.Errorf("checkResourcesForTags without filter mismatch (-want +got):\n%s", diff) } }) t.Run("With Taggability Filter - resource type not in map (should assume taggable)", func(t *testing.T) { // aws_route53_zone is NOT in this specific taggableMap taggableMap := map[string]bool{ "aws_s3_bucket": true, "aws_kms_alias": false, "aws_instance": true, } mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}} mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)} violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap, "test.tf") expectedViolations := []shared.Violation{ {ResourceType: "aws_s3_bucket", ResourceName: "taggable_bucket", Line: 2, MissingTags: []string{"Environment"}, FilePath: "test.tf"}, // aws_route53_zone is assumed taggable as it's not in the map with a 'false' entry {ResourceType: "aws_route53_zone", ResourceName: "unknown_in_schema_should_be_checked", Line: 22, MissingTags: []string{"Environment", "Owner"}, FilePath: "test.tf"}, } sortViolations(violations) sortViolations(expectedViolations) if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(shared.Violation{})); diff != "" { t.Errorf("checkResourcesForTags with incomplete filter mismatch (-want +got):\n%s", diff) } }) } // Helper to sort violations for consistent comparison func sortViolations(violations []shared.Violation) { for i := range violations { sort.Strings(violations[i].MissingTags) } sort.Slice(violations, func(i, j int) bool { if violations[i].ResourceType != violations[j].ResourceType { return violations[i].ResourceType < violations[j].ResourceType } return violations[i].ResourceName < violations[j].ResourceName }) } ================================================ FILE: internal/terraform/scan.go ================================================ package terraform import ( "errors" "io/fs" "path/filepath" ) // scan looks for tf files func scan(directoryPath string) (bool, error) { found := false targetExt := ".tf" walkErr := filepath.WalkDir(directoryPath, func(path string, d fs.DirEntry, err error) error { if err != nil { return nil } if !d.IsDir() { if filepath.Ext(path) == targetExt { found = true return fs.ErrNotExist // stop scan immediately } } return nil }) if walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) { return false, walkErr } return found, nil } ================================================ FILE: internal/terraform/scan_test.go ================================================ package terraform import ( "errors" "io/fs" "os" "path/filepath" "testing" ) func TestScanFunction(t *testing.T) { createDummyFile := func(t *testing.T, path string) { t.Helper() err := os.WriteFile(path, []byte("dummy content"), 0644) if err != nil { t.Fatalf("Failed to create dummy file %s: %v", path, err) } } tests := []struct { name string setupDir func(t *testing.T, dir string) expected bool }{ { name: "empty dir", setupDir: func(t *testing.T, dir string) { // dir is empty }, expected: false, }, { name: "no terraform files", setupDir: func(t *testing.T, dir string) { createDummyFile(t, filepath.Join(dir, "main.yaml")) createDummyFile(t, filepath.Join(dir, "README.md")) }, expected: false, }, { name: "terraform file", setupDir: func(t *testing.T, dir string) { createDummyFile(t, filepath.Join(dir, "main.tf")) createDummyFile(t, filepath.Join(dir, "other.txt")) }, expected: true, }, { name: "multiple terraform files", setupDir: func(t *testing.T, dir string) { createDummyFile(t, filepath.Join(dir, "main.tf")) createDummyFile(t, filepath.Join(dir, "variables.tf")) }, expected: true, }, { name: "nested terraform file", setupDir: func(t *testing.T, dir string) { subDir := filepath.Join(dir, "subdir") err := os.Mkdir(subDir, 0755) if err != nil { t.Fatalf("Failed to create subdir: %v", err) } createDummyFile(t, filepath.Join(subDir, "module.tf")) createDummyFile(t, filepath.Join(dir, "root.txt")) }, expected: true, }, { name: "terraform file, nested non-terraform file", setupDir: func(t *testing.T, dir string) { subDir := filepath.Join(dir, "subdir") err := os.Mkdir(subDir, 0755) if err != nil { t.Fatalf("Failed to create subdir: %v", err) } createDummyFile(t, filepath.Join(dir, "main.tf")) createDummyFile(t, filepath.Join(subDir, "other.yaml")) }, expected: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tmpDir := t.TempDir() testPath := tmpDir if tc.name == "Non-existent directory" { testPath = filepath.Join(tmpDir, "this_dir_should_not_exist") } else { tc.setupDir(t, tmpDir) } gotFound, gotErr := scan(testPath) if gotErr != nil && !errors.Is(gotErr, fs.ErrNotExist) { if tc.name != "Non-existent directory" { t.Logf("scan() returned an unexpected error: %v (test will check 'found' status only)", gotErr) } } if gotFound != tc.expected { t.Errorf("scan() found = %v, expected %v", gotFound, tc.expected) } }) } } ================================================ FILE: internal/terraform/types.go ================================================ package terraform import ( "github.com/hashicorp/hcl/v2" "github.com/jakebark/tag-nag/internal/shared" ) type DefaultTags struct { LiteralTags map[string]shared.TagMap } type TerraformContext struct { EvalContext *hcl.EvalContext } ================================================ FILE: main.go ================================================ package main import ( "log" "github.com/jakebark/tag-nag/internal/cloudformation" "github.com/jakebark/tag-nag/internal/inputs" "github.com/jakebark/tag-nag/internal/output" "github.com/jakebark/tag-nag/internal/shared" "github.com/jakebark/tag-nag/internal/terraform" ) func main() { log.SetFlags(0) // remove timestamp from prints userInput := inputs.ParseFlags() if userInput.DryRun { log.Printf("\033[32mDry-run: %s\033[0m\n", userInput.Directory) } else { log.Printf("\033[33mScanning: %s\033[0m\n", userInput.Directory) } tfViolations := terraform.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.Skip) cfnViolations := cloudformation.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.CfnSpecPath, userInput.Skip) var allViolations []shared.Violation allViolations = append(allViolations, tfViolations...) allViolations = append(allViolations, cfnViolations...) output.ProcessOutput(allViolations, userInput.OutputFormat, userInput.DryRun) } ================================================ FILE: main_test.go ================================================ package main import ( "bytes" "fmt" "os" "os/exec" "strings" "testing" ) const binaryName = "tag-nag" type testCases struct { name string filePathOrDir string cliArgs []string expectedExitCode int expectedError bool expectedOutput []string } func TestMain(m *testing.M) { cmd := exec.Command("go", "build", "-o", binaryName) if err := cmd.Run(); err != nil { fmt.Fprintf(os.Stderr, "Failed to build %s: %v\n", binaryName, err) os.Exit(1) } exitVal := m.Run() os.Remove(binaryName) os.Exit(exitVal) } func runTagNag(t *testing.T, args ...string) (string, error, int) { t.Helper() fullArgs := append([]string{"./" + binaryName}, args...) cmd := exec.Command(fullArgs[0], fullArgs[1:]...) var outbuf, errbuf bytes.Buffer cmd.Stdout = &outbuf cmd.Stderr = &errbuf err := cmd.Run() stdout := outbuf.String() stderr := errbuf.String() fullOutput := stdout if stderr != "" { fullOutput += "\n" + stderr } exitCode := 0 if err != nil { if exitError, ok := err.(*exec.ExitError); ok { exitCode = exitError.ExitCode() } else { t.Fatalf("Command execution failed with non-exit error: %v, output: %s", err, fullOutput) } } return fullOutput, err, exitCode } func TestInputs(t *testing.T) { testCases := []testCases{ { name: "no dir", filePathOrDir: "", cliArgs: []string{"--tags", "Owner"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{"Error: specify a directory or file to scan"}, }, { name: "no tags", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"nonexistent.yml"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{"specify required tags using --tags or create a .tag-nag.yml config file"}, }, { name: "dry run", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"--tags", "Owner,Environment,Project", "--dry-run"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"Dry-run:", `aws_s3_bucket "this"`, "Missing tags: Project"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { var argsForRun []string if tc.filePathOrDir != "" { argsForRun = append(argsForRun, tc.filePathOrDir) } argsForRun = append(argsForRun, tc.cliArgs...) output, err, exitCode := runTagNag(t, argsForRun...) if tc.expectedError && err == nil { t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output) } if !tc.expectedError && err != nil { t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output) } if exitCode != tc.expectedExitCode { t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output) } for _, expectedStr := range tc.expectedOutput { if !strings.Contains(output, expectedStr) { t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output) } } }) } } func TestTerraform(t *testing.T) { testCases := []testCases{ { name: "tags", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"--tags", "Owner,Environment"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "missing tags", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"--tags", "Owner,Environment,Project"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Project"}, }, { name: "no tags", filePathOrDir: "testdata/terraform/no_tags.tf", cliArgs: []string{"--tags", "Owner, Environment"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment, Owner"}, }, { name: "case insensitive", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"--tags", "owner,environment", "-c"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "lower case", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"--tags", "owner"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: owner"}, }, { name: "tag values", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"--tags", "Owner,Environment[dev,prod]"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "missing tag value", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"--tags", "Owner,Environment[test]"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment[test]"}, }, { name: "tag values case insensitive", filePathOrDir: "testdata/terraform/tags.tf", cliArgs: []string{"--tags", "Owner,Environment[Dev,Prod]", "-c"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "provider", filePathOrDir: "testdata/terraform/provider.tf", cliArgs: []string{"--tags", "Owner,Environment,Project,Source"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"Found Terraform default tags for provider aws", "No tag violations found"}, }, { name: "provider", filePathOrDir: "testdata/terraform/provider.tf", cliArgs: []string{"--tags", "Owner,Environment,Project,Source"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"Found Terraform default tags for provider aws: [Project, Source]", "No tag violations found"}, }, { name: "provider case insensitive", filePathOrDir: "testdata/terraform/provider.tf", cliArgs: []string{"--tags", "owner,environment,project,source", "-c"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"Found Terraform default tags for provider aws: [project, source]", "No tag violations found"}, }, { name: "provider tag values", filePathOrDir: "testdata/terraform/provider.tf", cliArgs: []string{"--tags", "Owner,Environment[dev,prod],Project,Source[my-repo]"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"Found Terraform default tags for provider aws: [Project, Source]", "No tag violations found"}, }, { name: "variable tags", filePathOrDir: "testdata/terraform/referenced_tags.tf", cliArgs: []string{"--tags", "Owner,Environment"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "variable value", filePathOrDir: "testdata/terraform/referenced_values.tf", cliArgs: []string{"--tags", "Owner[jakebark],Environment"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "local value", filePathOrDir: "testdata/terraform/referenced_values.tf", cliArgs: []string{"--tags", "Owner,Environment[dev,prod]"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "variable value case insensitive", filePathOrDir: "testdata/terraform/referenced_values.tf", cliArgs: []string{"--tags", "Owner[Jakebark],Environment", "-c"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "local value case insensitive", filePathOrDir: "testdata/terraform/referenced_values.tf", cliArgs: []string{"--tags", "Owner,Environment[DEV,PROD]", "-c"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "interpolation", filePathOrDir: "testdata/terraform/referenced_values.tf", cliArgs: []string{"--tags", "Owner,Environment,Project[112233],Source[my-repo]"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "interpolation missing value", filePathOrDir: "testdata/terraform/referenced_values.tf", cliArgs: []string{"--tags", "Owner,Environment,Project[112233],Source[not-my-repo]"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Source[not-my-repo]"}, }, { name: "example repo", filePathOrDir: "testdata/terraform/example_repo", cliArgs: []string{"--tags", "Owner,Environment"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{"Found Terraform default tags for provider aws: [Environment, Owner, Source]", `aws_s3_bucket "baz"`, "Found 1 tag violation(s)"}, }, { name: "ignore", filePathOrDir: "testdata/terraform/ignore.tf", cliArgs: []string{"--tags", "Owner,Environment,Project"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{`aws_s3_bucket "this" skipped`}, }, { name: "ignore all", filePathOrDir: "testdata/terraform/ignore_all.tf", cliArgs: []string{"--tags", "Owner,Environment,Project"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{`aws_s3_bucket "this" skipped`}, }, { name: "lower function", filePathOrDir: "testdata/terraform/function.tf", cliArgs: []string{"--tags", "Environment[dev]"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "skip file", filePathOrDir: "testdata/terraform", cliArgs: []string{"--tags", "Owner,Environment,Project", "-s", "testdata/terraform/tags.tf"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment, Owner"}, }, { name: "skip directory", filePathOrDir: "testdata", cliArgs: []string{"--tags", "Owner,Environment,Project", "-s", "testdata/terraform"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { argsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...) output, err, exitCode := runTagNag(t, argsForRun...) if tc.expectedError && err == nil { t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output) } if !tc.expectedError && err != nil { t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output) } if exitCode != tc.expectedExitCode { t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output) } for _, expectedStr := range tc.expectedOutput { if !strings.Contains(output, expectedStr) { t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output) } } }) } } func TestCloudFormation(t *testing.T) { testCases := []testCases{ { name: "yml", filePathOrDir: "testdata/cloudformation/tags.yml", cliArgs: []string{"--tags", "Owner,Environment"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "yaml", filePathOrDir: "testdata/cloudformation/tags.yaml", cliArgs: []string{"--tags", "Owner,Environment"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "json", filePathOrDir: "testdata/cloudformation/tags.json", cliArgs: []string{"--tags", "Owner,Environment"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "yaml missing tags", filePathOrDir: "testdata/cloudformation/tags.yml", cliArgs: []string{"--tags", "Owner,Environment,Project"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"}, }, { name: "json missing tags", filePathOrDir: "testdata/cloudformation/tags.json", cliArgs: []string{"--tags", "Owner,Environment,Project"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"}, }, { name: "case insensitive", filePathOrDir: "testdata/cloudformation/tags.yml", cliArgs: []string{"--tags", "owner,environment", "-c"}, expectedExitCode: 0, expectedError: false, expectedOutput: []string{"No tag violations found"}, }, { name: "lower case", filePathOrDir: "testdata/cloudformation/tags.yml", cliArgs: []string{"--tags", "owner"}, expectedExitCode: 1, expectedError: true, expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: owner"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { argsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...) output, err, exitCode := runTagNag(t, argsForRun...) if tc.expectedError && err == nil { t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output) } if !tc.expectedError && err != nil { t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output) } if exitCode != tc.expectedExitCode { t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output) } for _, expectedStr := range tc.expectedOutput { if !strings.Contains(output, expectedStr) { t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output) } } }) } } ================================================ FILE: testdata/cloudformation/tags.json ================================================ { "AWSTemplateFormatVersion": "2010-09-09", "Description": "single resource", "Resources": { "this": { "Type": "AWS::S3::Bucket", "Properties": { "BucketName": "test-bucket", "Tags": [ { "Key": "Owner", "Value": "jakebark" }, { "Key": "Environment", "Value": "dev" } ] } } } } ================================================ FILE: testdata/cloudformation/tags.yaml ================================================ AWSTemplateFormatVersion: '2010-09-09' Description: single resource Resources: this: Type: AWS::S3::Bucket Properties: BucketName: test-bucket Tags: - Key: Owner Value: jakebark - Key: Environment Value: dev ================================================ FILE: testdata/cloudformation/tags.yml ================================================ AWSTemplateFormatVersion: '2010-09-09' Description: single resource Resources: this: Type: AWS::S3::Bucket Properties: BucketName: test-bucket Tags: - Key: Owner Value: jakebark - Key: Environment Value: dev ================================================ FILE: testdata/config/blank_config.yml ================================================ ================================================ FILE: testdata/config/empty_settings.yml ================================================ tags: - key: Owner settings: skip: ================================================ FILE: testdata/config/full_config.yml ================================================ tags: - key: Owner - key: Environment values: [Dev, Test, Prod] - key: Project settings: case_insensitive: true dry_run: false cfn_spec: "/path/to/spec.json" skip: - "*.tmp" - ".terraform" - "test-data/**" ================================================ FILE: testdata/config/invalid_structure.yml ================================================ tags: "Owner,Project" ================================================ FILE: testdata/config/invalid_syntax.yml ================================================ tags: - key: Owner values: [Dev, Test ================================================ FILE: testdata/config/missing_tags.yml ================================================ settings: dry_run: true skip: - "*.tmp" ================================================ FILE: testdata/config/tag_array.yml ================================================ tags: - key: Owner values: "not-an-array" ================================================ FILE: testdata/config/tag_keys.yml ================================================ tags: - key: Owner - key: Project ================================================ FILE: testdata/config/tag_values.yml ================================================ tags: - key: Owner - key: Environment values: [Dev, Test, Prod] - key: Project ================================================ FILE: testdata/config/yaml_extension.yaml ================================================ tags: - key: Owner ================================================ FILE: testdata/terraform/example_repo/locals.tf ================================================ locals { tags = { Owner = "jakebark" Environment = var.environment Source = "my-repo" } } ================================================ FILE: testdata/terraform/example_repo/main.tf ================================================ resource "aws_s3_bucket" "foo" { bucket = "test-bucket" } resource "aws_s3_bucket" "bar" { bucket = "test-bucket" tags = { Project = "112233" } } resource "aws_s3_bucket" "baz" { bucket = "test-bucket" provider = aws.west } ================================================ FILE: testdata/terraform/example_repo/provider.tf ================================================ terraform { required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } provider "aws" { region = "us-east-1" default_tags { tags = local.tags } } provider "aws" { alias = "west" region = "us-west-1" } ================================================ FILE: testdata/terraform/example_repo/variables.tf ================================================ variable "environment" { type = string default = "dev" } ================================================ FILE: testdata/terraform/functions.tf ================================================ resource "aws_s3_bucket" "this" { bucket = "test-bucket" tags = { Owner = "jakebark" Environment = lower("Dev") } } ================================================ FILE: testdata/terraform/ignore.tf ================================================ resource "aws_s3_bucket" "this" { #tag-nag ignore bucket = "test-bucket" tags = { Owner = "jakebark" Environment = "dev" } } ================================================ FILE: testdata/terraform/ignore_all.tf ================================================ #tag-nag ignore-all resource "aws_s3_bucket" "this" { bucket = "test-bucket" } ================================================ FILE: testdata/terraform/no_tags.tf ================================================ resource "aws_s3_bucket" "this" { bucket = "test-bucket" } ================================================ FILE: testdata/terraform/provider.tf ================================================ provider "aws" { region = "us-east-1" default_tags { tags = { Project = "112233" Source = "my-repo" } } } resource "aws_s3_bucket" "this" { bucket = "test-bucket" tags = { Owner = "jakebark" Environment = "dev" } } ================================================ FILE: testdata/terraform/referenced_tags.tf ================================================ resource "aws_s3_bucket" "this" { bucket = "test-bucket" tags = var.tags } variable "tags" { type = map(string) default = { Owner = "jakebark" Environment = "dev" } } ================================================ FILE: testdata/terraform/referenced_values.tf ================================================ resource "aws_s3_bucket" "this" { bucket = "test-bucket" tags = { Owner = var.owner Environment = local.environment Project = "${local.project}" Source = "${local.source}" } } variable "owner" { type = string default = "jakebark" } variable "source" { type = string default = "my-repo" } locals { environment = "dev" project = "112233" source = var.source } ================================================ FILE: testdata/terraform/tags.tf ================================================ resource "aws_s3_bucket" "this" { bucket = "test-bucket" tags = { Owner = "jakebark" Environment = "dev" } }