├── testdata
├── config
│ ├── blank_config.yml
│ ├── invalid_structure.yml
│ ├── yaml_extension.yaml
│ ├── tag_keys.yml
│ ├── tag_array.yml
│ ├── empty_settings.yml
│ ├── invalid_syntax.yml
│ ├── missing_tags.yml
│ ├── tag_values.yml
│ └── full_config.yml
├── terraform
│ ├── no_tags.tf
│ ├── example_repo
│ │ ├── variables.tf
│ │ ├── locals.tf
│ │ ├── main.tf
│ │ └── provider.tf
│ ├── ignore_all.tf
│ ├── tags.tf
│ ├── functions.tf
│ ├── ignore.tf
│ ├── referenced_tags.tf
│ ├── provider.tf
│ └── referenced_values.tf
└── cloudformation
│ ├── tags.yaml
│ ├── tags.yml
│ └── tags.json
├── img
├── demo.gif
└── tag.png
├── internal
├── shared
│ ├── types.go
│ ├── helpers.go
│ └── helpers_test.go
├── cloudformation
│ ├── types.go
│ ├── scan.go
│ ├── helpers.go
│ ├── spec_loader_test.go
│ ├── spec_loader.go
│ ├── resources.go
│ ├── resources_test.go
│ ├── helpers_test.go
│ └── process.go
├── terraform
│ ├── types.go
│ ├── scan.go
│ ├── default_tags_test.go
│ ├── scan_test.go
│ ├── references.go
│ ├── helpers.go
│ ├── default_tags.go
│ ├── resources.go
│ ├── process.go
│ ├── helpers_test.go
│ └── resources_test.go
├── inputs
│ ├── loader.go
│ ├── inputs_test.go
│ ├── inputs.go
│ └── loader_test.go
└── config
│ └── config.go
├── examples
├── gitlab.yml
├── codebuild.yml
├── .tag-nag.yml
└── github.yml
├── .github
└── workflows
│ ├── semgrep.yml
│ ├── go_tests.yml
│ └── docker-hub.yml
├── .gitignore
├── go.mod
├── Dockerfile
├── LICENSE
├── main.go
├── go.sum
├── README.md
└── main_test.go
/testdata/config/blank_config.yml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/testdata/config/invalid_structure.yml:
--------------------------------------------------------------------------------
1 | tags: "Owner,Project"
2 |
--------------------------------------------------------------------------------
/testdata/config/yaml_extension.yaml:
--------------------------------------------------------------------------------
1 | tags:
2 | - key: Owner
--------------------------------------------------------------------------------
/testdata/config/tag_keys.yml:
--------------------------------------------------------------------------------
1 | tags:
2 | - key: Owner
3 | - key: Project
--------------------------------------------------------------------------------
/img/demo.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakebark/tag-nag/HEAD/img/demo.gif
--------------------------------------------------------------------------------
/img/tag.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jakebark/tag-nag/HEAD/img/tag.png
--------------------------------------------------------------------------------
/internal/shared/types.go:
--------------------------------------------------------------------------------
1 | package shared
2 |
3 | type TagMap map[string][]string
4 |
--------------------------------------------------------------------------------
/testdata/config/tag_array.yml:
--------------------------------------------------------------------------------
1 | tags:
2 | - key: Owner
3 | values: "not-an-array"
--------------------------------------------------------------------------------
/testdata/config/empty_settings.yml:
--------------------------------------------------------------------------------
1 | tags:
2 | - key: Owner
3 |
4 | settings:
5 |
6 | skip:
--------------------------------------------------------------------------------
/testdata/config/invalid_syntax.yml:
--------------------------------------------------------------------------------
1 | tags:
2 | - key: Owner
3 | values: [Dev, Test
4 |
--------------------------------------------------------------------------------
/testdata/config/missing_tags.yml:
--------------------------------------------------------------------------------
1 | settings:
2 | dry_run: true
3 |
4 | skip:
5 | - "*.tmp"
--------------------------------------------------------------------------------
/testdata/terraform/no_tags.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "this" {
2 | bucket = "test-bucket"
3 | }
4 |
5 |
--------------------------------------------------------------------------------
/testdata/terraform/example_repo/variables.tf:
--------------------------------------------------------------------------------
1 | variable "environment" {
2 | type = string
3 | default = "dev"
4 | }
5 |
--------------------------------------------------------------------------------
/testdata/terraform/ignore_all.tf:
--------------------------------------------------------------------------------
1 | #tag-nag ignore-all
2 | resource "aws_s3_bucket" "this" {
3 | bucket = "test-bucket"
4 | }
5 |
--------------------------------------------------------------------------------
/testdata/config/tag_values.yml:
--------------------------------------------------------------------------------
1 | tags:
2 | - key: Owner
3 | - key: Environment
4 | values: [Dev, Test, Prod]
5 | - key: Project
--------------------------------------------------------------------------------
/testdata/terraform/tags.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "this" {
2 | bucket = "test-bucket"
3 | tags = {
4 | Owner = "jakebark"
5 | Environment = "dev"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/testdata/terraform/example_repo/locals.tf:
--------------------------------------------------------------------------------
1 | locals {
2 | tags = {
3 | Owner = "jakebark"
4 | Environment = var.environment
5 | Source = "my-repo"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/testdata/terraform/functions.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "this" {
2 | bucket = "test-bucket"
3 | tags = {
4 | Owner = "jakebark"
5 | Environment = lower("Dev")
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/testdata/terraform/ignore.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "this" {
2 | #tag-nag ignore
3 | bucket = "test-bucket"
4 | tags = {
5 | Owner = "jakebark"
6 | Environment = "dev"
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/internal/cloudformation/types.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | type Violation struct {
4 | resourceName string
5 | resourceType string
6 | line int
7 | missingTags []string
8 | skip bool
9 | }
10 |
--------------------------------------------------------------------------------
/examples/gitlab.yml:
--------------------------------------------------------------------------------
1 | stages:
2 | - validate
3 |
4 | tag-nag:
5 | stage: validate
6 | image: jakebark/tag-nag:latest
7 | script:
8 | - terraform init -backend=false # remove for CloudFormation
9 | - tag-nag . --tags "tags"
10 |
--------------------------------------------------------------------------------
/examples/codebuild.yml:
--------------------------------------------------------------------------------
1 | ## codebuild image 'jakebar/tag-nag:$latest'
2 | version: 0.2
3 |
4 | phases:
5 | build:
6 | commands:
7 | - cd "$CODEBUILD_SRC_DIR"
8 | - terraform init -backend=false # remove for CloudFormation
9 | - tag-nag . --tags "tags"
10 |
--------------------------------------------------------------------------------
/testdata/terraform/referenced_tags.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "this" {
2 | bucket = "test-bucket"
3 | tags = var.tags
4 | }
5 |
6 | variable "tags" {
7 | type = map(string)
8 | default = {
9 | Owner = "jakebark"
10 | Environment = "dev"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/testdata/config/full_config.yml:
--------------------------------------------------------------------------------
1 | tags:
2 | - key: Owner
3 | - key: Environment
4 | values: [Dev, Test, Prod]
5 | - key: Project
6 |
7 | settings:
8 | case_insensitive: true
9 | dry_run: false
10 | cfn_spec: "/path/to/spec.json"
11 |
12 | skip:
13 | - "*.tmp"
14 | - ".terraform"
15 | - "test-data/**"
--------------------------------------------------------------------------------
/testdata/cloudformation/tags.yaml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: '2010-09-09'
2 | Description: single resource
3 | Resources:
4 | this:
5 | Type: AWS::S3::Bucket
6 | Properties:
7 | BucketName: test-bucket
8 | Tags:
9 | - Key: Owner
10 | Value: jakebark
11 | - Key: Environment
12 | Value: dev
13 |
--------------------------------------------------------------------------------
/testdata/cloudformation/tags.yml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: '2010-09-09'
2 | Description: single resource
3 | Resources:
4 | this:
5 | Type: AWS::S3::Bucket
6 | Properties:
7 | BucketName: test-bucket
8 | Tags:
9 | - Key: Owner
10 | Value: jakebark
11 | - Key: Environment
12 | Value: dev
13 |
--------------------------------------------------------------------------------
/testdata/terraform/example_repo/main.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "foo" {
2 | bucket = "test-bucket"
3 | }
4 |
5 | resource "aws_s3_bucket" "bar" {
6 | bucket = "test-bucket"
7 | tags = {
8 | Project = "112233"
9 | }
10 | }
11 |
12 | resource "aws_s3_bucket" "baz" {
13 | bucket = "test-bucket"
14 | provider = aws.west
15 | }
16 |
--------------------------------------------------------------------------------
/testdata/terraform/provider.tf:
--------------------------------------------------------------------------------
1 | provider "aws" {
2 | region = "us-east-1"
3 | default_tags {
4 | tags = {
5 | Project = "112233"
6 | Source = "my-repo"
7 | }
8 | }
9 | }
10 |
11 | resource "aws_s3_bucket" "this" {
12 | bucket = "test-bucket"
13 | tags = {
14 | Owner = "jakebark"
15 | Environment = "dev"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.github/workflows/semgrep.yml:
--------------------------------------------------------------------------------
1 | name: semgrep
2 |
3 | on:
4 | pull_request:
5 | branches: [ main ]
6 |
7 | jobs:
8 | semgrep:
9 | runs-on: ubuntu-latest
10 |
11 | container:
12 | image: semgrep/semgrep
13 |
14 | steps:
15 | - name: checkout code
16 | uses: actions/checkout@v4
17 |
18 | - name: run semgrep
19 | run: semgrep ci
20 |
--------------------------------------------------------------------------------
/testdata/terraform/example_repo/provider.tf:
--------------------------------------------------------------------------------
1 | terraform {
2 | required_providers {
3 | aws = {
4 | source = "hashicorp/aws"
5 | version = "~> 5.0"
6 | }
7 | }
8 | }
9 |
10 | provider "aws" {
11 | region = "us-east-1"
12 | default_tags {
13 | tags = local.tags
14 | }
15 | }
16 |
17 | provider "aws" {
18 | alias = "west"
19 | region = "us-west-1"
20 | }
21 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.exe
2 | *.exe~
3 | *.dll
4 | *.so
5 | *.dylib
6 |
7 | *.test
8 | **/test/*
9 | todo.md
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
14 | # Dependency directories (remove the comment below to include it)
15 | # vendor/
16 |
17 | # Go workspace file
18 | go.work
19 | go.work.sum
20 |
21 | # env file
22 | .env
23 |
24 | .DS_Store
25 |
--------------------------------------------------------------------------------
/examples/.tag-nag.yml:
--------------------------------------------------------------------------------
1 | tags:
2 | - key: Owner
3 | - key: Environment
4 | values: [Dev, Test, Prod]
5 | - key: Project
6 |
7 | settings:
8 | case_insensitive: false
9 | dry_run: false
10 | cfn_spec: "~/path/to/CloudFormationResourceSpecification.json" # remove if not using
11 |
12 | skip:
13 | - file.tf
14 | - "test-data/**" # keep quotes for wildcard/glob pattern
15 | - "*.tmp"
16 | - .terraform
17 | - .git
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.github/workflows/go_tests.yml:
--------------------------------------------------------------------------------
1 | name: go test
2 |
3 | on:
4 | pull_request:
5 | branches: [ main ]
6 |
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 |
11 | steps:
12 | - name: checkout code
13 | uses: actions/checkout@v4
14 |
15 | - name: setup go
16 | uses: actions/setup-go@v5
17 | with:
18 | go-version: '1.22'
19 | cache: true
20 |
21 | - name: run go tests
22 | run: go test -v ./...
23 |
--------------------------------------------------------------------------------
/internal/terraform/types.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "github.com/hashicorp/hcl/v2"
5 | "github.com/jakebark/tag-nag/internal/shared"
6 | )
7 |
8 | type DefaultTags struct {
9 | LiteralTags map[string]shared.TagMap
10 | }
11 |
12 | type Violation struct {
13 | resourceType string
14 | resourceName string
15 | line int
16 | missingTags []string
17 | skip bool
18 | }
19 |
20 | type TerraformContext struct {
21 | EvalContext *hcl.EvalContext
22 | }
23 |
--------------------------------------------------------------------------------
/examples/github.yml:
--------------------------------------------------------------------------------
1 | name: tag-nag
2 |
3 | on:
4 | pull_request:
5 | branches: [main]
6 |
7 | jobs:
8 | tag-nag:
9 | runs-on: ubuntu-latest
10 |
11 | container:
12 | image: jakebark/tag-nag:latest
13 |
14 | steps:
15 | - name: checkout code
16 | uses: actions/checkout@v4
17 |
18 | - name: run terraform init # remove for CloudFormation
19 | run: terraform init -backend=false
20 |
21 | - name: run tag-nag
22 | run: tag-nag . --tags "tags"
23 |
--------------------------------------------------------------------------------
/testdata/terraform/referenced_values.tf:
--------------------------------------------------------------------------------
1 | resource "aws_s3_bucket" "this" {
2 | bucket = "test-bucket"
3 | tags = {
4 | Owner = var.owner
5 | Environment = local.environment
6 | Project = "${local.project}"
7 | Source = "${local.source}"
8 | }
9 | }
10 |
11 | variable "owner" {
12 | type = string
13 | default = "jakebark"
14 | }
15 |
16 | variable "source" {
17 | type = string
18 | default = "my-repo"
19 | }
20 |
21 | locals {
22 | environment = "dev"
23 | project = "112233"
24 | source = var.source
25 | }
26 |
--------------------------------------------------------------------------------
/testdata/cloudformation/tags.json:
--------------------------------------------------------------------------------
1 | {
2 | "AWSTemplateFormatVersion": "2010-09-09",
3 | "Description": "single resource",
4 | "Resources": {
5 | "this": {
6 | "Type": "AWS::S3::Bucket",
7 | "Properties": {
8 | "BucketName": "test-bucket",
9 | "Tags": [
10 | {
11 | "Key": "Owner",
12 | "Value": "jakebark"
13 | },
14 | {
15 | "Key": "Environment",
16 | "Value": "dev"
17 | }
18 | ]
19 | }
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/internal/terraform/scan.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "errors"
5 | "io/fs"
6 | "path/filepath"
7 | )
8 |
9 | // scan looks for tf files
10 | func scan(dirPath string) (bool, error) {
11 | found := false
12 | targetExt := ".tf"
13 |
14 | walkErr := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
15 | if err != nil {
16 | return nil
17 | }
18 |
19 | if !d.IsDir() {
20 | if filepath.Ext(path) == targetExt {
21 | found = true
22 | return fs.ErrNotExist // stop scan immediately
23 | }
24 | }
25 | return nil
26 | })
27 |
28 | if walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) {
29 | return false, walkErr
30 | }
31 |
32 | return found, nil
33 | }
34 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/jakebark/tag-nag
2 |
3 | go 1.22
4 |
5 | require (
6 | github.com/hashicorp/hcl/v2 v2.23.0
7 | github.com/spf13/pflag v1.0.6
8 | github.com/zclconf/go-cty v1.13.0
9 | gopkg.in/yaml.v3 v3.0.1
10 | )
11 |
12 | require (
13 | github.com/agext/levenshtein v1.2.1 // indirect
14 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
15 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
16 | github.com/google/go-cmp v0.6.0 // indirect
17 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
18 | golang.org/x/mod v0.8.0 // indirect
19 | golang.org/x/sys v0.5.0 // indirect
20 | golang.org/x/text v0.11.0 // indirect
21 | golang.org/x/tools v0.6.0 // indirect
22 | )
23 |
--------------------------------------------------------------------------------
/internal/cloudformation/scan.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | import (
4 | "errors"
5 | "io/fs"
6 | "path/filepath"
7 | )
8 |
9 | // scan looks for cfn files
10 | func scan(dirPath string) (bool, error) {
11 | found := false
12 | targetExts := map[string]bool{
13 | ".yaml": true,
14 | ".yml": true,
15 | ".json": true,
16 | }
17 | walkErr := filepath.WalkDir(dirPath, func(path string, d fs.DirEntry, err error) error {
18 | if err != nil {
19 | return nil
20 | }
21 |
22 | if !d.IsDir() {
23 | if targetExts[filepath.Ext(path)] {
24 | found = true
25 | return fs.ErrNotExist // stop scan immediately
26 | }
27 | }
28 | return nil
29 | })
30 |
31 | if walkErr != nil && !errors.Is(walkErr, fs.ErrNotExist) {
32 | return false, walkErr
33 | }
34 | return found, nil
35 | }
36 |
--------------------------------------------------------------------------------
/internal/terraform/default_tags_test.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | // Test for normalizeProviderID
8 | func TestNormalizeProviderID(t *testing.T) {
9 | tests := []struct {
10 | name string
11 | providerName string
12 | alias string
13 | caseInsensitive bool
14 | expected string
15 | }{
16 | {
17 | name: "default",
18 | providerName: "aws",
19 | alias: "",
20 | expected: "aws",
21 | },
22 | {
23 | name: "alias",
24 | providerName: "aws",
25 | alias: "west",
26 | expected: "aws.west",
27 | },
28 | }
29 |
30 | for _, tc := range tests {
31 | t.Run(tc.name, func(t *testing.T) {
32 | if got := normalizeProviderID(tc.providerName, tc.alias, tc.caseInsensitive); got != tc.expected {
33 | t.Errorf("normalizeProviderID() = %v, expected %v", got, tc.expected)
34 | }
35 | })
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.22 AS builder
2 |
3 | WORKDIR /app
4 |
5 | COPY go.mod go.sum ./
6 | RUN go mod download
7 | RUN go mod tidy
8 |
9 | COPY . .
10 |
11 | RUN go build -o tag-nag
12 |
13 | FROM debian:stable-slim
14 |
15 | ARG TERRAFORM_VERSION
16 |
17 | RUN apt-get update && \
18 | apt-get install -y --no-install-recommends \
19 | wget \
20 | unzip \
21 | ca-certificates \
22 | git && \
23 | rm -rf /var/lib/apt/lists/*
24 |
25 | RUN wget "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \
26 | unzip "terraform_${TERRAFORM_VERSION}_linux_amd64.zip" -d /usr/local/bin && \
27 | rm "terraform_${TERRAFORM_VERSION}_linux_amd64.zip" && \
28 | chmod +x /usr/local/bin/terraform
29 |
30 | RUN groupadd -r appgroup && \
31 | useradd --no-log-init -r -g appgroup appuser
32 |
33 | COPY --from=builder /app/tag-nag /usr/local/bin/tag-nag
34 |
35 | USER appuser
36 |
37 | WORKDIR /workspace
38 |
39 | ENTRYPOINT ["tag-nag"]
40 |
41 | CMD ["--help"]
42 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 jakebark
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.github/workflows/docker-hub.yml:
--------------------------------------------------------------------------------
1 | name: publish to docker hub
2 |
3 | on:
4 | release:
5 | types: [published]
6 |
7 | jobs:
8 | build-and-push:
9 | runs-on: ubuntu-latest
10 | permissions:
11 | contents: read
12 | steps:
13 | - name: Check out the repo
14 | uses: actions/checkout@v4
15 |
16 | - name: Log in to Docker Hub
17 | uses: docker/login-action@v3
18 | with:
19 | username: ${{ secrets.DOCKER_USERNAME }}
20 | password: ${{ secrets.DOCKER_PASSWORD }}
21 |
22 | - name: Extract metadata (tags, labels) for Docker
23 | id: meta
24 | uses: docker/metadata-action@v5
25 | with:
26 | images: jakebark/tag-nag
27 |
28 | - name: Build and push Docker image
29 | id: push
30 | uses: docker/build-push-action@v6
31 | with:
32 | context: .
33 | file: ./Dockerfile
34 | push: true
35 | tags: ${{ steps.meta.outputs.tags }}
36 | labels: ${{ steps.meta.outputs.labels }}
37 | build-args: |
38 | TERRAFORM_VERSION=${{ vars.TERRAFORM_VERSION }}
39 |
40 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "log"
5 | "os"
6 |
7 | "github.com/jakebark/tag-nag/internal/cloudformation"
8 | "github.com/jakebark/tag-nag/internal/inputs"
9 | "github.com/jakebark/tag-nag/internal/terraform"
10 | )
11 |
12 | func main() {
13 | log.SetFlags(0) // remove timestamp from prints
14 |
15 | userInput := inputs.ParseFlags()
16 |
17 | if userInput.DryRun {
18 | log.Printf("\033[32mDry-run: %s\033[0m\n", userInput.Directory)
19 | } else {
20 | log.Printf("\033[33mScanning: %s\033[0m\n", userInput.Directory)
21 | }
22 |
23 | tfViolations := terraform.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.Skip)
24 | cfnViolations := cloudformation.ProcessDirectory(userInput.Directory, userInput.RequiredTags, userInput.CaseInsensitive, userInput.CfnSpecPath, userInput.Skip)
25 |
26 | violations := tfViolations + cfnViolations
27 |
28 | if violations > 0 && userInput.DryRun {
29 | log.Printf("\033[32mFound %d tag violation(s)\033[0m\n", violations)
30 | os.Exit(0)
31 | } else if violations > 0 {
32 | log.Printf("\033[31mFound %d tag violation(s)\033[0m\n", violations)
33 | os.Exit(1)
34 | } else {
35 | log.Println("No tag violations found")
36 | os.Exit(0)
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/internal/cloudformation/helpers.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | import (
4 | "os"
5 | "strings"
6 |
7 | "github.com/jakebark/tag-nag/internal/config"
8 | "gopkg.in/yaml.v3"
9 | )
10 |
11 | // mapNodes converts a yaml mapping node into a go map
12 | func mapNodes(node *yaml.Node) map[string]*yaml.Node {
13 | m := make(map[string]*yaml.Node)
14 | if node == nil || node.Kind != yaml.MappingNode {
15 | return m
16 | }
17 | for i := 0; i < len(node.Content); i += 2 {
18 | keyNode := node.Content[i]
19 | valueNode := node.Content[i+1]
20 | m[keyNode.Value] = valueNode
21 | }
22 | return m
23 | }
24 |
25 | // findMapNode parses a yaml block and returns the value, when given the key
26 | func findMapNode(node *yaml.Node, key string) *yaml.Node {
27 | if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
28 | node = node.Content[0]
29 | }
30 | if node.Kind != yaml.MappingNode {
31 | return nil
32 | }
33 | for i := 0; i < len(node.Content); i += 2 {
34 | k := node.Content[i]
35 | v := node.Content[i+1]
36 | if k.Value == key {
37 | return v
38 | }
39 | }
40 | return nil
41 | }
42 |
43 | // parseYAML unmarshal yaml and return a pointer to the root of the node
44 | func parseYAML(filePath string) (*yaml.Node, error) {
45 | data, err := os.ReadFile(filePath)
46 | if err != nil {
47 | return nil, err
48 | }
49 |
50 | var root yaml.Node
51 | if err := yaml.Unmarshal(data, &root); err != nil {
52 | return nil, err
53 | }
54 |
55 | if root.Kind == yaml.DocumentNode && len(root.Content) > 0 {
56 | root = *root.Content[0]
57 | }
58 | return &root, nil
59 | }
60 |
61 | func skipResource(node *yaml.Node, lines []string) bool {
62 | index := node.Line - 2
63 | if index < len(lines) {
64 | if strings.Contains(lines[index], config.TagNagIgnore) {
65 | return true
66 | }
67 | }
68 | return false
69 | }
70 |
--------------------------------------------------------------------------------
/internal/inputs/loader.go:
--------------------------------------------------------------------------------
1 | package inputs
2 |
3 | import (
4 | "fmt"
5 | "os"
6 |
7 | "github.com/jakebark/tag-nag/internal/config"
8 | "github.com/jakebark/tag-nag/internal/shared"
9 | "gopkg.in/yaml.v3"
10 | )
11 |
12 | type Config struct {
13 | Tags []TagDefinition `yaml:"tags"`
14 | Settings Settings `yaml:"settings"`
15 | Skip []string `yaml:"skip"`
16 | }
17 |
18 | type TagDefinition struct {
19 | Key string `yaml:"key"`
20 | Values []string `yaml:"values,omitempty"`
21 | }
22 |
23 | type Settings struct {
24 | CaseInsensitive bool `yaml:"case_insensitive"`
25 | DryRun bool `yaml:"dry_run"`
26 | CfnSpec string `yaml:"cfn_spec"`
27 | }
28 |
29 | // FindAndLoadConfigFile attempts to find and load configuration file
30 | func FindAndLoadConfigFile() (*Config, error) {
31 | if _, err := os.Stat(config.DefaultConfigFile); err == nil {
32 | return processConfigFile(config.DefaultConfigFile)
33 | }
34 |
35 | if _, err := os.Stat(config.AltConfigFile); err == nil {
36 | return processConfigFile(config.AltConfigFile)
37 | }
38 |
39 | return nil, nil
40 | }
41 |
42 | // processConfigFile reads the config file
43 | func processConfigFile(path string) (*Config, error) {
44 | data, err := os.ReadFile(path)
45 | if err != nil {
46 | return nil, fmt.Errorf("reading config file %s: %w", path, err)
47 | }
48 |
49 | var config Config
50 | if err := yaml.Unmarshal(data, &config); err != nil {
51 | return nil, fmt.Errorf("parsing config file %s: %w", path, err)
52 | }
53 |
54 | return &config, nil
55 | }
56 |
57 | // ConvertToTagMap converts config tags to internal TagMap format
58 | func (c *Config) convertToTagMap() shared.TagMap {
59 | tagMap := make(shared.TagMap)
60 |
61 | for _, tag := range c.Tags {
62 | tagMap[tag.Key] = tag.Values
63 | }
64 |
65 | return tagMap
66 | }
67 |
--------------------------------------------------------------------------------
/internal/cloudformation/spec_loader_test.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | import (
4 | "encoding/json"
5 | "testing"
6 | )
7 |
8 | // helper to create a test spec file
9 | func createTestSpec() *cfnSpec {
10 | specJSON := `{
11 | "PropertyTypes": {
12 | "Tag": {
13 | "Properties": {
14 | "Key": { "PrimitiveType": "String", "Required": true },
15 | "Value": { "PrimitiveType": "String", "Required": true }
16 | }
17 | },
18 | "OtherType": {
19 | "Properties": { "Name": { "PrimitiveType": "String" } }
20 | }
21 | },
22 | "ResourceTypes": {}
23 | }`
24 | var spec cfnSpec
25 | if err := json.Unmarshal([]byte(specJSON), &spec); err != nil {
26 | panic("Failed to unmarshal base test spec data: " + err.Error())
27 | }
28 | return &spec
29 | }
30 |
31 | func TestIsTaggable(t *testing.T) {
32 | baseSpec := createTestSpec()
33 |
34 | tests := []struct {
35 | name string
36 | resourceJSON string
37 | want bool
38 | }{
39 | {
40 | name: "taggable",
41 | resourceJSON: `{
42 | "Properties": {
43 | "Name": { "PrimitiveType": "String" },
44 | "Tags": { "Type": "List", "ItemType": "Tag", "Required": false }
45 | }
46 | }`,
47 | want: true,
48 | },
49 | {
50 | name: "not taggable",
51 | resourceJSON: `{
52 | "Properties": {
53 | "Name": { "PrimitiveType": "String" }
54 | }
55 | }`,
56 | want: false,
57 | },
58 | }
59 |
60 | for _, tc := range tests {
61 | t.Run(tc.name, func(t *testing.T) {
62 | var resourceDef cfnResourceType
63 | if err := json.Unmarshal([]byte(tc.resourceJSON), &resourceDef); err != nil {
64 | t.Fatalf("Failed to unmarshal test resource JSON: %v", err)
65 | }
66 |
67 | if got := resourceDef.isTaggable(baseSpec); got != tc.want {
68 | t.Errorf("isTaggable() = %v, want %v", got, tc.want)
69 | }
70 | })
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/internal/shared/helpers.go:
--------------------------------------------------------------------------------
1 | package shared
2 |
3 | import (
4 | "fmt"
5 | "sort"
6 | "strings"
7 | )
8 |
9 | // FilterMissingTags checks effectiveTags against requiredTags
10 | func FilterMissingTags(requiredTags TagMap, effectiveTags TagMap, caseInsensitive bool) []string {
11 | var missingTags []string
12 |
13 | for reqKey, allowedValues := range requiredTags {
14 | effectiveValues, keyFound := matchTagKey(reqKey, effectiveTags, caseInsensitive)
15 |
16 | // construct violation message
17 | violationMessage := reqKey
18 | if len(allowedValues) > 0 {
19 | violationMessage = fmt.Sprintf("%s[%s]", reqKey, strings.Join(allowedValues, ","))
20 | }
21 | if !keyFound {
22 | missingTags = append(missingTags, violationMessage)
23 | continue
24 | }
25 |
26 | // if there are tag values required, check them
27 | if len(allowedValues) > 0 {
28 | if !matchTagValue(allowedValues, effectiveValues, caseInsensitive) {
29 | missingTags = append(missingTags, violationMessage)
30 | }
31 | }
32 | }
33 |
34 | sort.Strings(missingTags) //
35 | return missingTags
36 | }
37 |
38 | // matchTagKey checks required tag key against effective tags
39 | func matchTagKey(reqKey string, effectiveTags TagMap, caseInsensitive bool) (values []string, found bool) {
40 | for effKey, effValues := range effectiveTags {
41 | if caseInsensitive {
42 | if strings.EqualFold(effKey, reqKey) {
43 | return effValues, true
44 | }
45 | } else {
46 | if effKey == reqKey {
47 | return effValues, true
48 | }
49 | }
50 | }
51 | return nil, false
52 | }
53 |
54 | // matchTagValue checks required tag values (if present) against effective tags
55 | func matchTagValue(allowedValues []string, effectiveValues []string, caseInsensitive bool) bool {
56 | if len(allowedValues) == 0 { // if no tag alues are required, return match
57 | return true
58 | }
59 | if len(effectiveValues) == 0 && len(allowedValues) > 0 {
60 | return false
61 | }
62 |
63 | for _, allowed := range allowedValues {
64 | for _, effVal := range effectiveValues {
65 | if caseInsensitive {
66 | if strings.EqualFold(effVal, allowed) {
67 | return true
68 | }
69 | } else {
70 | if effVal == allowed {
71 | return true
72 | }
73 | }
74 | }
75 | }
76 | return false
77 | }
78 |
--------------------------------------------------------------------------------
/internal/cloudformation/spec_loader.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "os"
8 | "strings"
9 | )
10 |
11 | type cfnSpec struct {
12 | ResourceTypes map[string]cfnResourceType `json:"ResourceTypes"`
13 | PropertyTypes map[string]cfnPropertyType `json:"PropertyTypes"`
14 | }
15 |
16 | type cfnResourceType struct {
17 | Properties map[string]cfnProperty `json:"Properties"`
18 | }
19 |
20 | type cfnProperty struct {
21 | Required bool `json:"Required"`
22 | Type string `json:"Type"` // list
23 | ItemType string `json:"ItemType"` // tag
24 | PrimitiveType string `json:"PrimitiveType"`
25 | PrimitiveItemType string `json:"PrimitiveItemType"`
26 | // Other fields ignored
27 | }
28 |
29 | type cfnPropertyType struct {
30 | Properties map[string]cfnProperty `json:"Properties"`
31 | }
32 |
33 | // isTaggable checks the cfn spec file to see if a resource can be tagged
34 | func (rt cfnResourceType) isTaggable(specData *cfnSpec) bool {
35 | tagsProp, ok := rt.Properties["Tags"]
36 | if !ok {
37 | return false
38 | }
39 | if tagsProp.Type == "List" && tagsProp.ItemType == "Tag" {
40 | tagTypeDef, tagTypeExists := specData.PropertyTypes["Tag"]
41 | if tagTypeExists {
42 | _, keyExists := tagTypeDef.Properties["Key"]
43 | _, valueExists := tagTypeDef.Properties["Value"]
44 | return keyExists && valueExists
45 | }
46 | }
47 | return false
48 | }
49 |
50 | // LoadTaggableResourcesFromSpec parses the provided spec file path
51 | func loadTaggableResourcesFromSpec(specFilePath string) (map[string]bool, error) {
52 | log.Printf("Attempting to load CloudFormation specification from: %s", specFilePath)
53 | data, err := os.ReadFile(specFilePath)
54 | if err != nil {
55 | return nil, fmt.Errorf("failed to read CloudFormation spec file '%s': %w", specFilePath, err)
56 | }
57 |
58 | var specData cfnSpec
59 | err = json.Unmarshal(data, &specData)
60 | if err != nil {
61 | return nil, fmt.Errorf("failed to parse CloudFormation spec JSON from '%s': %w", specFilePath, err)
62 | }
63 |
64 | if specData.ResourceTypes == nil {
65 | return nil, fmt.Errorf("invalid CloudFormation spec format: missing 'ResourceTypes' in '%s'", specFilePath)
66 | }
67 | if specData.PropertyTypes == nil {
68 | return nil, fmt.Errorf("invalid CloudFormation spec format: missing 'PropertyTypes' in '%s'", specFilePath)
69 | }
70 |
71 | taggableMap := make(map[string]bool)
72 | for resourceName, resourceDef := range specData.ResourceTypes {
73 | if strings.HasPrefix(resourceName, "AWS::") {
74 | taggableMap[resourceName] = resourceDef.isTaggable(&specData)
75 | }
76 | }
77 |
78 | log.Printf("Loaded %d AWS resource types.", len(taggableMap))
79 | return taggableMap, nil
80 | }
81 |
--------------------------------------------------------------------------------
/internal/config/config.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "github.com/zclconf/go-cty/cty/function"
5 | "github.com/zclconf/go-cty/cty/function/stdlib"
6 | )
7 |
8 | const (
9 | TagNagIgnore = "#tag-nag ignore"
10 | TagNagIgnoreAll = "#tag-nag ignore-all"
11 | DefaultConfigFile = ".tag-nag.yml"
12 | AltConfigFile = ".tag-nag.yaml"
13 | )
14 |
15 | var SkippedDirs = []string{
16 | ".terraform",
17 | ".git",
18 | }
19 |
20 | // terraform functions, used when evaluating context of locals and vars
21 | // added manually, no reasonable workaround to auto-import all
22 | // https://developer.hashicorp.com/terraform/language/functions
23 | // https://pkg.go.dev/github.com/zclconf/go-cty@v1.16.2/cty/function/stdlib
24 | var StdlibFuncs = map[string]function.Function{
25 | "abs": stdlib.AbsoluteFunc,
26 | "ceil": stdlib.CeilFunc,
27 | "chomp": stdlib.ChompFunc,
28 | "chunklist": stdlib.ChunklistFunc,
29 | "coalesce": stdlib.CoalesceFunc,
30 | "coalescelist": stdlib.CoalesceListFunc,
31 | "compact": stdlib.CompactFunc,
32 | "concat": stdlib.ConcatFunc,
33 | "contains": stdlib.ContainsFunc,
34 | "csvdecode": stdlib.CSVDecodeFunc,
35 | "distinct": stdlib.DistinctFunc,
36 | "element": stdlib.ElementFunc,
37 | "flatten": stdlib.FlattenFunc,
38 | "floor": stdlib.FloorFunc,
39 | "format": stdlib.FormatFunc,
40 | "formatdate": stdlib.FormatDateFunc,
41 | "formatlist": stdlib.FormatListFunc,
42 | "indent": stdlib.IndentFunc,
43 | "index": stdlib.IndexFunc,
44 | "int": stdlib.IntFunc,
45 | "join": stdlib.JoinFunc,
46 | "jsondecode": stdlib.JSONDecodeFunc,
47 | "jsonencode": stdlib.JSONEncodeFunc,
48 | "keys": stdlib.KeysFunc,
49 | "length": stdlib.LengthFunc,
50 | "log": stdlib.LogFunc,
51 | "lookup": stdlib.LookupFunc,
52 | "lower": stdlib.LowerFunc,
53 | "max": stdlib.MaxFunc,
54 | "merge": stdlib.MergeFunc,
55 | "min": stdlib.MinFunc,
56 | "parseint": stdlib.ParseIntFunc,
57 | "pow": stdlib.PowFunc,
58 | "range": stdlib.RangeFunc,
59 | "regex": stdlib.RegexFunc,
60 | "regexall": stdlib.RegexAllFunc,
61 | "regexreplace": stdlib.RegexReplaceFunc,
62 | "replace": stdlib.ReplaceFunc,
63 | "reverse": stdlib.ReverseFunc,
64 | "reverselist": stdlib.ReverseListFunc,
65 | "setunion": stdlib.SetUnionFunc,
66 | "slice": stdlib.SliceFunc,
67 | "sort": stdlib.SortFunc,
68 | "split": stdlib.SplitFunc,
69 | "trim": stdlib.TrimFunc,
70 | "trimprefix": stdlib.TrimPrefixFunc,
71 | "trimspace": stdlib.TrimSpaceFunc,
72 | "trimsuffix": stdlib.TrimSuffixFunc,
73 | "upper": stdlib.UpperFunc,
74 | "values": stdlib.ValuesFunc,
75 | }
76 |
--------------------------------------------------------------------------------
/internal/terraform/scan_test.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "errors"
5 | "io/fs"
6 | "os"
7 | "path/filepath"
8 | "testing"
9 | )
10 |
11 | func TestScanFunction(t *testing.T) {
12 | createDummyFile := func(t *testing.T, path string) {
13 | t.Helper()
14 | err := os.WriteFile(path, []byte("dummy content"), 0644)
15 | if err != nil {
16 | t.Fatalf("Failed to create dummy file %s: %v", path, err)
17 | }
18 | }
19 |
20 | tests := []struct {
21 | name string
22 | setupDir func(t *testing.T, dir string)
23 | expected bool
24 | }{
25 | {
26 | name: "empty dir",
27 | setupDir: func(t *testing.T, dir string) {
28 | // dir is empty
29 | },
30 | expected: false,
31 | },
32 | {
33 | name: "no terraform files",
34 | setupDir: func(t *testing.T, dir string) {
35 | createDummyFile(t, filepath.Join(dir, "main.yaml"))
36 | createDummyFile(t, filepath.Join(dir, "README.md"))
37 | },
38 | expected: false,
39 | },
40 | {
41 | name: "terraform file",
42 | setupDir: func(t *testing.T, dir string) {
43 | createDummyFile(t, filepath.Join(dir, "main.tf"))
44 | createDummyFile(t, filepath.Join(dir, "other.txt"))
45 | },
46 | expected: true,
47 | },
48 | {
49 | name: "multiple terraform files",
50 | setupDir: func(t *testing.T, dir string) {
51 | createDummyFile(t, filepath.Join(dir, "main.tf"))
52 | createDummyFile(t, filepath.Join(dir, "variables.tf"))
53 | },
54 | expected: true,
55 | },
56 | {
57 | name: "nested terraform file",
58 | setupDir: func(t *testing.T, dir string) {
59 | subDir := filepath.Join(dir, "subdir")
60 | err := os.Mkdir(subDir, 0755)
61 | if err != nil {
62 | t.Fatalf("Failed to create subdir: %v", err)
63 | }
64 | createDummyFile(t, filepath.Join(subDir, "module.tf"))
65 | createDummyFile(t, filepath.Join(dir, "root.txt"))
66 | },
67 | expected: true,
68 | },
69 | {
70 | name: "terraform file, nested non-terraform file",
71 | setupDir: func(t *testing.T, dir string) {
72 | subDir := filepath.Join(dir, "subdir")
73 | err := os.Mkdir(subDir, 0755)
74 | if err != nil {
75 | t.Fatalf("Failed to create subdir: %v", err)
76 | }
77 | createDummyFile(t, filepath.Join(dir, "main.tf"))
78 | createDummyFile(t, filepath.Join(subDir, "other.yaml"))
79 | },
80 | expected: true,
81 | },
82 | }
83 |
84 | for _, tc := range tests {
85 | t.Run(tc.name, func(t *testing.T) {
86 | tmpDir := t.TempDir()
87 | testPath := tmpDir
88 | if tc.name == "Non-existent directory" {
89 | testPath = filepath.Join(tmpDir, "this_dir_should_not_exist")
90 | } else {
91 | tc.setupDir(t, tmpDir)
92 | }
93 | gotFound, gotErr := scan(testPath)
94 | if gotErr != nil && !errors.Is(gotErr, fs.ErrNotExist) {
95 | if tc.name != "Non-existent directory" {
96 | t.Logf("scan() returned an unexpected error: %v (test will check 'found' status only)", gotErr)
97 | }
98 | }
99 | if gotFound != tc.expected {
100 | t.Errorf("scan() found = %v, expected %v", gotFound, tc.expected)
101 | }
102 | })
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/internal/cloudformation/resources.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strings"
7 |
8 | "github.com/jakebark/tag-nag/internal/shared"
9 | "gopkg.in/yaml.v3"
10 | )
11 |
12 | // getResourceViolations inspects resource blocks and returns violations
13 | func checkResourcesforTags(resourcesMapping map[string]*yaml.Node, requiredTags shared.TagMap, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool) []Violation {
14 | var violations []Violation
15 |
16 | for resourceName, resourceNode := range resourcesMapping { // resourceNode == yaml node for resource
17 | resourceMapping := mapNodes(resourceNode)
18 |
19 | typeNode, ok := resourceMapping["Type"]
20 | if !ok || !strings.HasPrefix(typeNode.Value, "AWS::") {
21 | continue
22 | }
23 | resourceType := typeNode.Value
24 |
25 | if taggable != nil {
26 | isTaggable, found := taggable[resourceType]
27 | if found && !isTaggable {
28 | continue
29 | }
30 | }
31 |
32 | properties := make(map[string]interface{}) // tags are part of the properties node
33 | if propsNode, ok := resourceMapping["Properties"]; ok {
34 | _ = propsNode.Decode(&properties)
35 | }
36 |
37 | tags, err := extractTagMap(properties, caseInsensitive)
38 | if err != nil {
39 | log.Printf("Error extracting tags from resource %s: %v\n", resourceName, err)
40 | continue
41 | }
42 |
43 | missing := shared.FilterMissingTags(requiredTags, tags, caseInsensitive)
44 | if len(missing) > 0 {
45 | violation := Violation{
46 | resourceName: resourceName,
47 | resourceType: resourceType,
48 | line: resourceNode.Line,
49 | missingTags: missing,
50 | }
51 | // if file-level or resource-level ignore is found
52 | if skipAll || skipResource(resourceNode, fileLines) {
53 | violation.skip = true
54 | }
55 | violations = append(violations, violation)
56 | }
57 | }
58 | return violations
59 | }
60 |
61 | // extractTagMap extracts a yaml/json map to a go map
62 | func extractTagMap(properties map[string]interface{}, caseInsensitive bool) (shared.TagMap, error) {
63 | tagsMap := make(shared.TagMap)
64 | literalTags, exists := properties["Tags"]
65 | if !exists {
66 | return tagsMap, nil
67 | }
68 |
69 | tagsList, ok := literalTags.([]interface{})
70 | if !ok {
71 | return tagsMap, fmt.Errorf("Tags format is invalid") // tags are not in a list
72 | }
73 |
74 | for _, tagInterface := range tagsList {
75 | tagEntry, ok := tagInterface.(map[string]interface{})
76 | if !ok {
77 | continue
78 | }
79 | key, ok := tagEntry["Key"].(string)
80 | if !ok {
81 | continue
82 | }
83 | var tagValue string
84 | if valStr, ok := tagEntry["Value"].(string); ok {
85 | tagValue = valStr
86 | } else if refMap, ok := tagEntry["Value"].(map[string]interface{}); ok {
87 | if ref, exists := refMap["Ref"]; exists {
88 | if refStr, ok := ref.(string); ok {
89 | tagValue = fmt.Sprintf("!Ref %s", refStr)
90 | }
91 | }
92 | }
93 | if caseInsensitive {
94 | key = strings.ToLower(key)
95 | }
96 | tagsMap[key] = []string{tagValue}
97 | }
98 | return tagsMap, nil
99 | }
100 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
2 | github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
3 | github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
4 | github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
5 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
6 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
7 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
8 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
9 | github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
10 | github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
13 | github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
14 | github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
15 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
16 | github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
17 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
18 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
19 | github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0=
20 | github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
21 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo=
22 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM=
23 | golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
24 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
25 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
26 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
27 | golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
28 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
29 | golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
30 | golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
31 | golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
32 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
33 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
34 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
35 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
36 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # tag-nag
2 |
3 |
4 |
5 | Validate AWS tags in Terraform and CloudFormation.
6 |
7 | ## Installation
8 | ```bash
9 | go install github.com/jakebark/tag-nag@latest
10 | ```
11 | You may need to set [GOPATH](https://go.dev/wiki/SettingGOPATH).
12 |
13 | ## Commands
14 |
15 | Tag-nag will search a file or directory for tag keys. Directory search is recursive.
16 |
17 | ```bash
18 | tag-nag --tags "Key1,Key2"
19 |
20 | tag-nag main.tf --tags "Owner" # run against a file
21 | tag-nag ./my_project --tags "Owner,Environment" # run against a directory
22 | tag-nag . --tags "Owner", "Environment" # will take string or list
23 |
24 | ```
25 |
26 | Search for tag keys *and* values
27 |
28 | ```bash
29 | tag-nag --tags "Key[Value]"
30 |
31 | tag-nag main.tf --tags "Owner[Jake]"
32 | tag-nag main.tf --tags "Owner[Jake],Environment" # mixed search possible
33 | tag-nag main.tf --tags "Owner[Jake],Environment[Dev,Prod]" # multiple options for tag values
34 |
35 | ```
36 |
37 | Optional flags
38 | ```bash
39 | -c # case-insensitive
40 | -d # dry-run (will always exit successfully)
41 | ```
42 |
43 | Optional inputs
44 | ```bash
45 | --cfn-spec ~/path/to/CloudFormationResourceSpecification.json # path to Cfn spec file, filters taggable resources
46 | --skip "file.tf, path/to/directory" # skip files and directories
47 | ```
48 |
49 | ## Config file
50 |
51 | The above commands can be issued with a `.tag-nag.yml` file in the same directory where tag-nag is run.
52 |
53 | See the [example .tag-nag.yml file](./examples/.tag-nag.yml).
54 |
55 | ## Skip Checks
56 |
57 | Skip file
58 | ```hcl
59 | #tag-nag ignore-all
60 | ```
61 |
62 | Terraform
63 | ```hcl
64 | resource "aws_s3_bucket" "this" {
65 | #tag-nag ignore
66 | bucket = "that"
67 | }
68 | ```
69 |
70 | CloudFormation
71 | ```yaml
72 | EC2Instance: #tag-nag ignore
73 | Type: "AWS::EC2::Instance"
74 | Properties:
75 | ImageId: ami-12a34b
76 | InstanceType: c1.xlarge
77 | ```
78 |
79 | ## Filtering taggable resources
80 |
81 | Some AWS resources cannot be tagged.
82 |
83 | To filter out these resources with Terraform, run tag-nag against an initialised directory (`terraform init`).
84 |
85 | To filter out these resources with CloudFormation, specify a path to the [CloudFormation JSON spec file](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-resource-specification.html) with the `--cfn-spec` input.
86 |
87 | ## Docker
88 | Run
89 | ```bash
90 | docker pull jakebark/tag-nag:latest
91 | docker run --rm -v $(pwd):/workspace -w /workspace jakebark/tag-nag \
92 | . --tags "Owner,Environment"
93 |
94 | ```
95 |
96 | Interactive shell
97 | ```bash
98 | docker pull jakebark/tag-nag:latest
99 | docker run -it --rm \
100 | -v "$(pwd)":/workspace \
101 | -w /workspace \
102 | --entrypoint /bin/sh jakebark/tag-nag:latest
103 | ```
104 |
105 | The image contains terraform, allowing `terraform init` to be run, if required.
106 | ```bash
107 | docker pull jakebark/tag-nag:latest
108 | docker run --rm -v $(pwd):/workspace -w /workspace \
109 | --entrypoint /bin/sh jakebark/tag-nag:latest \
110 | -c "terraform init -input=false -no-color && tag-nag\
111 | . --tags 'Owner,Environment'"
112 | ```
113 |
114 | ## CI/CD
115 |
116 | Example CI files:
117 | - [GitHub](./examples/github.yml)
118 | - [GitLab](./examples/gitlab.yml)
119 | - [AWS CodeBuild](./examples/codebuild.yml)
120 |
121 | ## Related Resources
122 |
123 | - [pkg.go.dev/github.com/jakebark/tag-nag](https://pkg.go.dev/github.com/jakebark/tag-nag)
124 |
125 |
126 |

127 |
128 |
--------------------------------------------------------------------------------
/internal/cloudformation/resources_test.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/google/go-cmp/cmp"
7 | "github.com/jakebark/tag-nag/internal/shared"
8 | )
9 |
10 | func TestExtractTagMap(t *testing.T) {
11 | tests := []struct {
12 | name string
13 | properties map[string]interface{}
14 | caseInsensitive bool
15 | expected shared.TagMap
16 | expectedErr bool
17 | }{
18 | {
19 | name: "no tag key",
20 | properties: map[string]interface{}{
21 | "OtherKey": "Value"},
22 | expected: shared.TagMap{},
23 | },
24 | {
25 | name: "empty list",
26 | properties: map[string]interface{}{
27 | "Tags": []interface{}{},
28 | },
29 | expected: shared.TagMap{},
30 | },
31 | {
32 | name: "not a list",
33 | properties: map[string]interface{}{
34 | "Tags": map[string]string{"Key": "Value"},
35 | },
36 | expectedErr: true,
37 | },
38 | {
39 | name: "literal tags",
40 | properties: map[string]interface{}{
41 | "Tags": []interface{}{
42 | map[string]interface{}{"Key": "Owner", "Value": "Jake"},
43 | map[string]interface{}{"Key": "Env", "Value": "Dev"},
44 | },
45 | },
46 | expected: shared.TagMap{
47 | "Owner": []string{"Jake"},
48 | "Env": []string{"Dev"},
49 | },
50 | },
51 | // {
52 | // name: "referenced tags",
53 | // properties: map[string]interface{}{
54 | // "Tags": []interface{}{
55 | // map[string]interface{}{"Key": "StackName", "Value": map[string]interface{}{"Ref": "AWS::StackName"}},
56 | // },
57 | // },
58 | // expected: shared.TagMap{
59 | // "StackName": []string{"!Ref StackName"},
60 | // },
61 | // },
62 | // {
63 | // name: "mixed tags, literal and referenced",
64 | // properties: map[string]interface{}{
65 | // "Tags": []interface{}{
66 | // map[string]interface{}{"Key": "Owner", "Value": "Jake"},
67 | // map[string]interface{}{"Key": "StackName", "Value": map[string]interface{}{"Ref": "AWS::StackName"}},
68 | // },
69 | // },
70 | // expected: shared.TagMap{
71 | // "Owner": []string{"Jake"},
72 | // "StackName": []string{"!Ref StackName"},
73 | // },
74 | // },
75 | {
76 | name: "literal tags, case insensitive",
77 | properties: map[string]interface{}{
78 | "Tags": []interface{}{
79 | map[string]interface{}{"Key": "Owner", "Value": "Jake"},
80 | map[string]interface{}{"Key": "env", "Value": "Dev"},
81 | },
82 | },
83 | caseInsensitive: true,
84 | expected: shared.TagMap{
85 | "owner": []string{"Jake"},
86 | "env": []string{"Dev"},
87 | },
88 | },
89 | {
90 | name: "missing key",
91 | properties: map[string]interface{}{
92 | "Tags": []interface{}{
93 | map[string]interface{}{"Value": "Jake"},
94 | },
95 | },
96 | expected: shared.TagMap{},
97 | },
98 | {
99 | name: "missing value",
100 | properties: map[string]interface{}{
101 | "Tags": []interface{}{
102 | map[string]interface{}{"Key": "OptionalTag"},
103 | },
104 | },
105 | expected: shared.TagMap{
106 | "OptionalTag": []string{""},
107 | },
108 | },
109 | {
110 | name: "non-string",
111 | properties: map[string]interface{}{
112 | "Tags": []interface{}{
113 | map[string]interface{}{"Key": 123, "Value": "Jake"},
114 | },
115 | },
116 | expected: shared.TagMap{},
117 | },
118 | }
119 |
120 | for _, tc := range tests {
121 | t.Run(tc.name, func(t *testing.T) {
122 | got, err := extractTagMap(tc.properties, tc.caseInsensitive)
123 | if (err != nil) != tc.expectedErr {
124 | t.Fatalf("extractTagMap() error = %v, expectedErr %v", err, tc.expectedErr)
125 | }
126 | if !tc.expectedErr {
127 | if diff := cmp.Diff(tc.expected, got); diff != "" {
128 | t.Errorf("extractTagMap() mismatch (-expected +got):\n%s", diff)
129 | }
130 | }
131 | })
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/internal/cloudformation/helpers_test.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | import (
4 | "testing"
5 |
6 | "gopkg.in/yaml.v3"
7 | )
8 |
9 | // helper to create a yaml.Node for testing
10 | func createYamlNode(t *testing.T, yamlStr string) *yaml.Node {
11 | t.Helper()
12 | var node yaml.Node
13 | err := yaml.Unmarshal([]byte(yamlStr), &node)
14 | if err != nil {
15 | t.Fatalf("Failed to unmarshal test YAML: %v\nYAML:\n%s", err, yamlStr)
16 | }
17 | if node.Kind == yaml.DocumentNode && len(node.Content) > 0 {
18 | return node.Content[0]
19 | }
20 | return &node
21 | }
22 |
23 | func testMapNodes(t *testing.T) {
24 | tests := []struct {
25 | name string
26 | inputYAML string
27 | expectedKeys []string
28 | }{
29 | {
30 | name: "simple map",
31 | inputYAML: `Key1: Value1\nKey2: 123`,
32 | expectedKeys: []string{"Key1", "Key2"},
33 | },
34 | {
35 | name: "nested map",
36 | inputYAML: `Key1: Value1\nKey2:\n Nested1: NestedValue`,
37 | expectedKeys: []string{"Key1", "Key2"},
38 | },
39 | {
40 | name: "empty map",
41 | inputYAML: `{}`,
42 | expectedKeys: []string{},
43 | },
44 | }
45 |
46 | for _, tc := range tests {
47 | t.Run(tc.name, func(t *testing.T) {
48 | node := createYamlNode(t, tc.inputYAML)
49 | mapped := mapNodes(node)
50 | gotKeys := make([]string, 0, len(mapped))
51 | for k := range mapped {
52 | gotKeys = append(gotKeys, k)
53 | }
54 | if len(gotKeys) != len(tc.expectedKeys) {
55 | t.Errorf("mapNodes() returned map with %d keys, expected %d. Got keys: %v", len(gotKeys), len(tc.expectedKeys), gotKeys)
56 | return
57 | }
58 | })
59 | }
60 | }
61 |
62 | func testFindMapNode(t *testing.T) {
63 | yamlContent := `
64 | RootKey: RootValue
65 | Map1:
66 | NestedKey1: NestedValue1
67 | NestedKey2: 123
68 | Map2: {}
69 | List1:
70 | - itemA
71 | - itemB
72 | `
73 | rootNode := createYamlNode(t, yamlContent)
74 |
75 | tests := []struct {
76 | name string
77 | node *yaml.Node
78 | key string
79 | expectedNil bool
80 | expectedKind yaml.Kind
81 | expectedValue string
82 | }{
83 | {
84 | name: "root key to root value",
85 | node: rootNode,
86 | key: "RootKey",
87 | expectedNil: false,
88 | expectedKind: yaml.ScalarNode,
89 | expectedValue: "RootValue",
90 | },
91 | {
92 | name: "map key to map values",
93 | node: rootNode,
94 | key: "Map1",
95 | expectedNil: false,
96 | expectedKind: yaml.MappingNode,
97 | },
98 | {
99 | name: "map key to empty map value",
100 | node: rootNode,
101 | key: "Map2",
102 | expectedNil: false,
103 | expectedKind: yaml.MappingNode,
104 | },
105 | {
106 | name: "list key to list values",
107 | node: rootNode,
108 | key: "List1",
109 | expectedNil: false,
110 | expectedKind: yaml.SequenceNode,
111 | },
112 | {
113 | name: "missing key",
114 | node: rootNode,
115 | key: "MissingKey",
116 | expectedNil: true,
117 | },
118 | {
119 | name: "search other node",
120 | node: findMapNode(rootNode, "List1"),
121 | key: "itemA",
122 | expectedNil: true,
123 | },
124 | }
125 |
126 | for _, tc := range tests {
127 | t.Run(tc.name, func(t *testing.T) {
128 | got := findMapNode(tc.node, tc.key)
129 | isNil := got == nil
130 | if isNil != tc.expectedNil {
131 | t.Fatalf("findMapNode(key=%q) returned nil? %t, expectedNil %t", tc.key, isNil, tc.expectedNil)
132 | }
133 | if !tc.expectedNil {
134 | if got.Kind != tc.expectedKind {
135 | t.Errorf("findMapNode(key=%q) returned node kind %v, expected %v", tc.key, got.Kind, tc.expectedKind)
136 | }
137 | if tc.expectedKind == yaml.ScalarNode && got.Value != tc.expectedValue {
138 | t.Errorf("findMapNode(key=%q) returned scalar value %q, expected %q", tc.key, got.Value, tc.expectedValue)
139 | }
140 | }
141 | })
142 | }
143 | }
144 |
145 |
--------------------------------------------------------------------------------
/internal/cloudformation/process.go:
--------------------------------------------------------------------------------
1 | package cloudformation
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "path/filepath"
8 | "strings"
9 |
10 | "github.com/jakebark/tag-nag/internal/config"
11 | "github.com/jakebark/tag-nag/internal/shared"
12 | )
13 |
14 | // ProcessDirectory walks all cfn files in a directory, then returns violations
15 | func ProcessDirectory(dirPath string, requiredTags map[string][]string, caseInsensitive bool, specFilePath string, skip []string) int {
16 | hasFiles, err := scan(dirPath)
17 | if err != nil {
18 | return 0
19 | }
20 | if !hasFiles {
21 | return 0
22 | }
23 |
24 | // log.Println("\nCloudFormation files found")
25 | var totalViolations int
26 |
27 | var taggable map[string]bool
28 | if specFilePath != "" {
29 | var loadSpecErr error
30 | taggable, loadSpecErr = loadTaggableResourcesFromSpec(specFilePath)
31 | if loadSpecErr != nil {
32 | log.Printf("Warning: Could not load or parse --cfn-spec file '%s': %v.", specFilePath, loadSpecErr)
33 | taggable = nil
34 | } else {
35 | // log.Println("Parsing CloudFormation spec file.")
36 | }
37 | }
38 |
39 | walkErr := filepath.Walk(dirPath, func(path string, info os.FileInfo, walkErr error) error {
40 | if walkErr != nil {
41 | log.Printf("Error accessing %q: %v\n", path, walkErr)
42 | return walkErr
43 | }
44 |
45 | for _, skipped := range skip {
46 | if strings.HasPrefix(path, skipped) {
47 | if info.IsDir() {
48 | return filepath.SkipDir
49 | }
50 | return nil
51 | }
52 | }
53 | if info.IsDir() {
54 | dirName := info.Name()
55 | for _, skippedDir := range config.SkippedDirs {
56 | if dirName == skippedDir {
57 | return filepath.SkipDir
58 | }
59 | }
60 | }
61 |
62 | if info.IsDir() {
63 | dirName := info.Name()
64 | for _, skipped := range config.SkippedDirs {
65 | if dirName == skipped {
66 | return filepath.SkipDir
67 | }
68 | }
69 | }
70 |
71 | if !info.IsDir() && (filepath.Ext(path) == ".yaml" || filepath.Ext(path) == ".yml" || filepath.Ext(path) == ".json") {
72 | violations, processErr := processFile(path, requiredTags, caseInsensitive, taggable)
73 | if processErr != nil {
74 | log.Printf("Error processing file %s: %v\n", path, processErr)
75 | return nil // Example: Continue walking
76 | }
77 | totalViolations += len(violations)
78 | }
79 | return nil
80 | })
81 | if walkErr != nil {
82 | log.Printf("Error scanning directory %s: %v\n", dirPath, walkErr)
83 | }
84 | return totalViolations
85 | }
86 |
87 | // processFile parses files and maps the cfn nodes
88 | func processFile(filePath string, requiredTags shared.TagMap, caseInsensitive bool, taggable map[string]bool) ([]Violation, error) {
89 | data, err := os.ReadFile(filePath)
90 | if err != nil {
91 | log.Printf("Error reading %s: %v\n", filePath, err)
92 | return nil, fmt.Errorf("reading file %s: %w", filePath, err)
93 | }
94 | content := string(data)
95 | lines := strings.Split(content, "\n")
96 |
97 | skipAll := strings.Contains(content, config.TagNagIgnoreAll)
98 |
99 | root, err := parseYAML(filePath)
100 | if err != nil {
101 | return nil, fmt.Errorf("parsing file %s: %w", filePath, err)
102 | }
103 |
104 | // search root node for resources node
105 | resourcesMapping := mapNodes(findMapNode(root, "Resources"))
106 | if resourcesMapping == nil {
107 | log.Printf("No 'Resources' section found in %s\n", filePath)
108 | return []Violation{}, nil
109 | }
110 |
111 | violations := checkResourcesforTags(resourcesMapping, requiredTags, caseInsensitive, lines, skipAll, taggable)
112 |
113 | if len(violations) > 0 {
114 | fmt.Printf("\nViolation(s) in %s\n", filePath)
115 | for _, v := range violations {
116 | if v.skip {
117 | fmt.Printf(" %d: %s \"%s\" skipped\n", v.line, v.resourceType, v.resourceName)
118 | } else {
119 | fmt.Printf(" %d: %s \"%s\" 🏷️ Missing tags: %s\n", v.line, v.resourceType, v.resourceName, strings.Join(v.missingTags, ", "))
120 | }
121 | }
122 | }
123 |
124 | var filteredViolations []Violation
125 | for _, v := range violations {
126 | if !v.skip {
127 | filteredViolations = append(filteredViolations, v)
128 | }
129 | }
130 | return filteredViolations, nil
131 | }
132 |
--------------------------------------------------------------------------------
/internal/terraform/references.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "path/filepath"
8 |
9 | "github.com/hashicorp/hcl/v2"
10 | "github.com/hashicorp/hcl/v2/hclparse"
11 | "github.com/hashicorp/hcl/v2/hclsyntax"
12 | "github.com/jakebark/tag-nag/internal/config"
13 | "github.com/zclconf/go-cty/cty"
14 | "github.com/zclconf/go-cty/cty/function"
15 | )
16 |
17 | func buildTagContext(dirPath string) (*TerraformContext, error) {
18 | parsedFiles := make(map[string]*hcl.File)
19 | parser := hclparse.NewParser()
20 |
21 | // first pass, parse files
22 | err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
23 | if err != nil {
24 | return err
25 | }
26 | if info.IsDir() {
27 | dirName := info.Name()
28 | for _, skipped := range config.SkippedDirs {
29 | if dirName == skipped {
30 | return filepath.SkipDir
31 | }
32 | }
33 | }
34 | if !info.IsDir() && filepath.Ext(path) == ".tf" {
35 | file, diags := parser.ParseHCLFile(path)
36 | if diags.HasErrors() {
37 | log.Printf("Error parsing HCL file %s: %v\n", path, diags)
38 | }
39 | if file != nil {
40 | parsedFiles[path] = file
41 | }
42 | }
43 | return nil
44 | })
45 | if err != nil {
46 | return nil, fmt.Errorf("error walking directory %s: %w", dirPath, err)
47 | }
48 |
49 | if len(parsedFiles) == 0 {
50 | log.Println("No Terraform files (.tf) found to build context.")
51 | return &TerraformContext{
52 | EvalContext: &hcl.EvalContext{
53 | Variables: make(map[string]cty.Value),
54 | Functions: make(map[string]function.Function),
55 | },
56 | }, nil
57 | }
58 |
59 | // second pass, evaluate vars
60 | tfVars := make(map[string]cty.Value)
61 | for _, file := range parsedFiles {
62 | body, ok := file.Body.(*hclsyntax.Body)
63 | if !ok {
64 | continue
65 | }
66 |
67 | for _, block := range body.Blocks {
68 | if block.Type == "variable" && len(block.Labels) > 0 {
69 | varName := block.Labels[0]
70 | if defaultAttr, exists := block.Body.Attributes["default"]; exists {
71 | val, diags := defaultAttr.Expr.Value(nil)
72 | if diags.HasErrors() {
73 | log.Printf("Error evaluating default for variable %q: %v", varName, diags)
74 | val = cty.NullVal(cty.DynamicPseudoType)
75 | }
76 | tfVars[varName] = val
77 | } else {
78 | tfVars[varName] = cty.NullVal(cty.DynamicPseudoType)
79 | }
80 | }
81 | }
82 | }
83 |
84 | // 3rd pass, evaluate locals
85 | tfLocals := make(map[string]cty.Value)
86 | localsDefs := make(map[string]hcl.Expression)
87 |
88 | for _, file := range parsedFiles {
89 | body, ok := file.Body.(*hclsyntax.Body)
90 | if !ok {
91 | continue
92 | }
93 | for _, block := range body.Blocks {
94 | if block.Type == "locals" {
95 | for name, attr := range block.Body.Attributes {
96 | localsDefs[name] = attr.Expr
97 | }
98 | }
99 | }
100 | }
101 |
102 | evalCtxForLocals := &hcl.EvalContext{
103 | Variables: map[string]cty.Value{"var": cty.ObjectVal(tfVars)},
104 | Functions: config.StdlibFuncs,
105 | }
106 | evalCtxForLocals.Variables["local"] = cty.NullVal(cty.DynamicPseudoType) // Placeholder for local
107 |
108 | const maxLocalPasses = 10
109 | evaluatedCount := 0
110 | for pass := 0; pass < maxLocalPasses && evaluatedCount < len(localsDefs); pass++ {
111 | madeProgress := false
112 |
113 | evalCtxForLocals.Variables["local"] = cty.ObjectVal(tfLocals)
114 |
115 | for name, expr := range localsDefs {
116 | if _, exists := tfLocals[name]; exists {
117 | continue
118 | }
119 |
120 | val, diags := expr.Value(evalCtxForLocals)
121 | if !diags.HasErrors() {
122 | tfLocals[name] = val
123 | evaluatedCount++
124 | madeProgress = true
125 | }
126 |
127 | }
128 | if !madeProgress && evaluatedCount < len(localsDefs) {
129 | log.Printf("Warning: Could not resolve all locals dependencies after %d passes.", pass+1)
130 |
131 | break
132 | }
133 | }
134 |
135 | finalCtx := &hcl.EvalContext{
136 | Variables: map[string]cty.Value{
137 | "var": cty.ObjectVal(tfVars),
138 | "local": cty.ObjectVal(tfLocals),
139 | },
140 | Functions: config.StdlibFuncs,
141 | }
142 |
143 | return &TerraformContext{EvalContext: finalCtx}, nil
144 | }
145 |
--------------------------------------------------------------------------------
/internal/inputs/inputs_test.go:
--------------------------------------------------------------------------------
1 | package inputs
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 |
7 | "github.com/jakebark/tag-nag/internal/shared"
8 | )
9 |
10 | func TestParseTags(t *testing.T) {
11 | testCases := []struct {
12 | name string
13 | input string
14 | expected shared.TagMap
15 | expectedError bool
16 | }{
17 | {
18 | name: "key",
19 | input: "Owner",
20 | expected: shared.TagMap{
21 | "Owner": {},
22 | },
23 | expectedError: false,
24 | },
25 | {
26 | name: "multiple keys",
27 | input: "Owner, Environment , Project",
28 | expected: shared.TagMap{
29 | "Owner": {},
30 | "Environment": {},
31 | "Project": {},
32 | },
33 | expectedError: false,
34 | },
35 | {
36 | name: "mixed keys and values",
37 | input: "Owner[jake], Environment[Dev,Prod], CostCenter",
38 | expected: shared.TagMap{
39 | "Owner": {"jake"},
40 | "Environment": {"Dev", "Prod"},
41 | "CostCenter": {},
42 | },
43 | expectedError: false,
44 | },
45 | {
46 | name: "empty",
47 | input: "",
48 | expected: shared.TagMap{},
49 | expectedError: false,
50 | },
51 | {
52 | name: "whitespace",
53 | input: " , ",
54 | expected: shared.TagMap{},
55 | expectedError: false,
56 | },
57 | {
58 | name: "mixed keys and values, with whitespace",
59 | input: " Owner , Environment[Dev, Prod] ",
60 | expected: shared.TagMap{
61 | "Owner": {},
62 | "Environment": {"Dev", "Prod"},
63 | },
64 | expectedError: false,
65 | },
66 | {
67 | name: "leading comma",
68 | input: ",Owner",
69 | expected: shared.TagMap{
70 | "Owner": {},
71 | },
72 | expectedError: false,
73 | },
74 | {
75 | name: "missing value",
76 | input: "Env[]",
77 | expected: shared.TagMap{
78 | "Env": {}, // No values extracted
79 | },
80 | expectedError: false,
81 | },
82 | {
83 | name: "missing value, other values present",
84 | input: "Env[Dev,,Prod]",
85 | expected: shared.TagMap{
86 | "Env": {"Dev", "Prod"},
87 | },
88 | expectedError: false,
89 | },
90 | {
91 | name: "whitespace preserved",
92 | input: "Owner[it belongs to me]",
93 | expected: shared.TagMap{
94 | "Owner": {"it belongs to me"},
95 | },
96 | expectedError: false,
97 | },
98 | {
99 | name: "unclosed bracket",
100 | input: "invalid[value",
101 | expected: nil,
102 | expectedError: true,
103 | },
104 | {
105 | name: "no key",
106 | input: "[value]",
107 | expected: nil,
108 | expectedError: true,
109 | },
110 | {
111 | name: "stray bracket",
112 | input: "stray]",
113 | expected: nil,
114 | expectedError: true,
115 | },
116 | }
117 |
118 | for _, tc := range testCases {
119 | t.Run(tc.name, func(t *testing.T) {
120 | actual, err := parseTags(tc.input)
121 | if tc.expectedError {
122 | if err == nil {
123 | t.Errorf("parseTags(%q) expected an error, but got nil", tc.input)
124 | }
125 | return
126 | }
127 | if err != nil {
128 | t.Errorf("parseTags(%q) expected no error, but got: %v", tc.input, err)
129 | }
130 | if !reflect.DeepEqual(actual, tc.expected) {
131 | t.Errorf("parseTags(%q) = %v; want %v", tc.input, actual, tc.expected)
132 | }
133 | })
134 | }
135 | }
136 |
137 | func TestSplitTags(t *testing.T) {
138 | testCases := []struct {
139 | name string
140 | input string
141 | expected []string
142 | }{
143 | {"empty", "", []string{""}},
144 | {"key", "Owner", []string{"Owner"}},
145 | {"multiple keys", "Owner, Env , Project", []string{"Owner", "Env", "Project"}},
146 | {"value", "Owner[Jake]", []string{"Owner[Jake]"}},
147 | {"multiple values", "Env[Dev,Prod]", []string{"Env[Dev,Prod]"}},
148 | {"mixed keys and values", "Owner[Jake], Env[Dev,Prod], CostCenter", []string{"Owner[Jake]", "Env[Dev,Prod]", "CostCenter"}},
149 | {"trailing comma", "Owner,Env,", []string{"Owner", "Env", ""}},
150 | {"leading comma", ",Owner,Env", []string{"", "Owner", "Env"}},
151 | }
152 |
153 | for _, tc := range testCases {
154 | t.Run(tc.name, func(t *testing.T) {
155 | actual := splitTags(tc.input)
156 | if !reflect.DeepEqual(actual, tc.expected) {
157 | t.Errorf("splitTags(%q) = %v; want %v", tc.input, actual, tc.expected)
158 | }
159 | })
160 | }
161 | }
162 |
--------------------------------------------------------------------------------
/internal/terraform/helpers.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "log"
7 | "os/exec"
8 | "strings"
9 |
10 | "github.com/hashicorp/hcl/v2"
11 | "github.com/hashicorp/hcl/v2/hclsyntax"
12 | "github.com/jakebark/tag-nag/internal/config"
13 | "github.com/jakebark/tag-nag/internal/shared"
14 | "github.com/zclconf/go-cty/cty"
15 | ctyjson "github.com/zclconf/go-cty/cty/json"
16 | )
17 |
18 | // traversalToString converts a hcl hierachical/traversal string to a literal string
19 | func traversalToString(expr hcl.Expression, caseInsensitive bool) string {
20 | if ste, ok := expr.(*hclsyntax.ScopeTraversalExpr); ok {
21 | tokens := []string{}
22 | for _, step := range ste.Traversal {
23 | switch t := step.(type) {
24 | case hcl.TraverseRoot:
25 | tokens = append(tokens, t.Name)
26 | case hcl.TraverseAttr:
27 | tokens = append(tokens, t.Name)
28 | }
29 | }
30 | result := strings.Join(tokens, ".")
31 | if caseInsensitive {
32 | result = strings.ToLower(result)
33 | }
34 | return result
35 | }
36 | // fallback - attempt to evaluate the expression as a literal value
37 | if v, diags := expr.Value(nil); !diags.HasErrors() {
38 | if v.Type().Equals(cty.String) {
39 | s := v.AsString()
40 | if caseInsensitive {
41 | s = strings.ToLower(s)
42 | }
43 | return s
44 | } else {
45 | return fmt.Sprintf("%v", v)
46 | }
47 | }
48 | return ""
49 | }
50 |
51 | // mergeTags combines multiple tag maps
52 | func mergeTags(tagMaps ...shared.TagMap) shared.TagMap {
53 | merged := make(shared.TagMap)
54 | for _, m := range tagMaps {
55 | for k, v := range m {
56 | merged[k] = v
57 | }
58 | }
59 | return merged
60 | }
61 |
62 | // SkipResource determines if a resource block should be skipped
63 | func SkipResource(block *hclsyntax.Block, lines []string) bool {
64 | index := block.DefRange().Start.Line
65 | if index < len(lines) {
66 | if strings.Contains(lines[index], config.TagNagIgnore) {
67 | return true
68 | }
69 | }
70 | return false
71 | }
72 |
73 | func convertCtyValueToString(val cty.Value) (string, error) {
74 | if !val.IsKnown() {
75 | return "", fmt.Errorf("value is unknown")
76 | }
77 | if val.IsNull() {
78 | return "", nil
79 | }
80 |
81 | ty := val.Type()
82 | switch {
83 | case ty == cty.String:
84 | return val.AsString(), nil
85 | case ty == cty.Number:
86 | bf := val.AsBigFloat()
87 | return bf.Text('f', -1), nil
88 | case ty == cty.Bool:
89 | return fmt.Sprintf("%t", val.True()), nil
90 | case ty.IsListType() || ty.IsTupleType() || ty.IsSetType() || ty.IsMapType() || ty.IsObjectType():
91 |
92 | simpleJSON, err := ctyjson.SimpleJSONValue{Value: val}.MarshalJSON()
93 | if err != nil {
94 | return "", fmt.Errorf("failed to marshal complex type to json: %w", err)
95 | }
96 | strJSON := string(simpleJSON)
97 | if len(strJSON) >= 2 && strJSON[0] == '"' && strJSON[len(strJSON)-1] == '"' {
98 | var unquotedStr string
99 | if err := json.Unmarshal(simpleJSON, &unquotedStr); err == nil {
100 | return unquotedStr, nil
101 | }
102 | }
103 | return strJSON, nil
104 | default:
105 | return fmt.Sprintf("%v", val), nil // Best effort
106 | }
107 | }
108 |
109 | // loadTaggableResources calls the Terraform JSON schema and returns a set of all resources that are taggable
110 | func loadTaggableResources(providerAddr string) map[string]bool {
111 | out, err := exec.Command(
112 | "terraform", "providers", "schema", "-json",
113 | ).Output()
114 | if err != nil {
115 | // log.Printf("Failed to load AWS terraform provider schema: %v", err)
116 | return nil
117 | }
118 |
119 | // unmarshall what we need
120 | var s struct {
121 | ProviderSchemas map[string]struct {
122 | ResourceSchemas map[string]struct {
123 | Block struct {
124 | Attributes map[string]json.RawMessage `json:"attributes"`
125 | } `json:"block"`
126 | } `json:"resource_schemas"`
127 | } `json:"provider_schemas"`
128 | }
129 | if err := json.Unmarshal(out, &s); err != nil {
130 | log.Fatalf("failed to parse schema JSON: %v", err)
131 | return nil
132 | }
133 |
134 | taggable := make(map[string]bool)
135 | if ps, ok := s.ProviderSchemas[providerAddr]; ok {
136 | for resType, schema := range ps.ResourceSchemas {
137 | if _, has := schema.Block.Attributes["tags"]; has {
138 | taggable[resType] = true
139 | } else { //
140 | taggable[resType] = false
141 | }
142 | }
143 | } else {
144 | return nil
145 | }
146 |
147 | return taggable
148 | }
149 |
--------------------------------------------------------------------------------
/internal/terraform/default_tags.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "sort"
7 | "strings"
8 |
9 | "github.com/hashicorp/hcl/v2/hclparse"
10 | "github.com/hashicorp/hcl/v2/hclsyntax"
11 | "github.com/jakebark/tag-nag/internal/shared"
12 | "github.com/zclconf/go-cty/cty"
13 | )
14 |
15 | // processDefaultTags identifies the default tags
16 | func processDefaultTags(tfFiles []tfFile, tfCtx *TerraformContext, caseInsensitive bool) DefaultTags {
17 | defaultTags := DefaultTags{
18 | LiteralTags: make(map[string]shared.TagMap),
19 | }
20 |
21 | parser := hclparse.NewParser()
22 |
23 | for _, tf := range tfFiles {
24 | file, diags := parser.ParseHCLFile(tf.path)
25 | if diags.HasErrors() || file == nil {
26 | log.Printf("Error parsing %s during default tag scan: %v\n", tf.path, diags)
27 | continue
28 | }
29 |
30 | syntaxBody, ok := file.Body.(*hclsyntax.Body)
31 | if !ok {
32 | log.Printf("Failed to get syntax body for %s\n", tf.path)
33 | continue
34 | }
35 |
36 | processProviders(syntaxBody, &defaultTags, tfCtx, caseInsensitive)
37 | }
38 |
39 | return defaultTags
40 | }
41 |
42 | // processProviders extracts any default_tags from providers
43 | func processProviders(body *hclsyntax.Body, defaultTags *DefaultTags, tfCtx *TerraformContext, caseInsensitive bool) {
44 | for _, block := range body.Blocks {
45 | if block.Type == "provider" && len(block.Labels) > 0 {
46 | providerID := getProviderID(block, caseInsensitive) // handle ID
47 | tags := getDefaultTags(block, tfCtx, caseInsensitive) // handle tags
48 |
49 | if len(tags) > 0 {
50 | var keys []string
51 | for key := range tags {
52 | keys = append(keys, key) // remove bool element of tag map
53 | }
54 | sort.Strings(keys)
55 | fmt.Printf("Found Terraform default tags for provider %s: [%v]\n", providerID, strings.Join(keys, ", "))
56 | defaultTags.LiteralTags[providerID] = tags
57 |
58 | }
59 | }
60 | }
61 | }
62 |
63 | // getProviderID returns the provider identifier (aws or alias)
64 | func getProviderID(block *hclsyntax.Block, caseInsensitive bool) string {
65 | providerName := block.Labels[0]
66 | var alias string
67 |
68 | // check for alias presence
69 | if attr, ok := block.Body.Attributes["alias"]; ok {
70 | val, diags := attr.Expr.Value(nil)
71 | if !diags.HasErrors() {
72 | alias = val.AsString()
73 | }
74 | }
75 | return normalizeProviderID(providerName, alias, caseInsensitive)
76 | }
77 |
78 | // normalize ProviderID combines the provider name and alias ("aws.west"), aligning with resource provider naming
79 | func normalizeProviderID(providerName, alias string, caseInsensitive bool) string {
80 | providerID := providerName
81 | if alias != "" {
82 | providerID += "." + alias
83 | }
84 |
85 | if caseInsensitive {
86 | providerID = strings.ToLower(providerID)
87 | }
88 |
89 | return providerID
90 | }
91 |
92 | // getDefaultTags returns the default_tags on a provider block.
93 | func getDefaultTags(block *hclsyntax.Block, tfCtx *TerraformContext, caseInsensitive bool) shared.TagMap { // Add tfCtx param
94 | for _, subBlock := range block.Body.Blocks {
95 | if subBlock.Type == "default_tags" {
96 | if tagsAttr, exists := subBlock.Body.Attributes["tags"]; exists {
97 | tagsVal, diags := tagsAttr.Expr.Value(tfCtx.EvalContext)
98 | if diags.HasErrors() {
99 | log.Printf("Error evaluating default_tags expression for provider %v: %v", block.Labels, diags)
100 | return nil
101 | }
102 |
103 | if !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() {
104 | log.Printf("Warning: Evaluated default_tags for provider %v is not an object/map type, but %s. Skipping.", block.Labels, tagsVal.Type().FriendlyName())
105 | return nil
106 | }
107 | if tagsVal.IsNull() {
108 | log.Printf("Warning: Evaluated default_tags for provider %v is null. Skipping.", block.Labels)
109 | return nil
110 | }
111 |
112 | evalTags := make(shared.TagMap)
113 | for key, val := range tagsVal.AsValueMap() {
114 | var valStr string
115 | if val.IsNull() {
116 | valStr = ""
117 | } else if val.Type() == cty.String {
118 | valStr = val.AsString()
119 | } else {
120 | strResult, err := convertCtyValueToString(val)
121 | if err != nil {
122 | log.Printf("Warning: Could not convert default tag value for key %q to string: %v. Using empty string.", key, err)
123 | valStr = ""
124 | } else {
125 | valStr = strResult
126 | }
127 | }
128 |
129 | effectiveKey := key
130 | if caseInsensitive {
131 | effectiveKey = strings.ToLower(key)
132 | }
133 | evalTags[effectiveKey] = []string{valStr}
134 | }
135 | return evalTags
136 | }
137 | }
138 | }
139 | return nil // No default_tags block found
140 | }
141 |
--------------------------------------------------------------------------------
/internal/terraform/resources.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "log"
5 | "strings"
6 |
7 | "github.com/hashicorp/hcl/v2/hclsyntax"
8 | "github.com/jakebark/tag-nag/internal/shared"
9 | "github.com/zclconf/go-cty/cty"
10 | )
11 |
12 | // checkResourcesForTags inspects resource blocks and returns violations
13 | func checkResourcesForTags(body *hclsyntax.Body, requiredTags shared.TagMap, defaultTags *DefaultTags, tfCtx *TerraformContext, caseInsensitive bool, fileLines []string, skipAll bool, taggable map[string]bool) []Violation {
14 | var violations []Violation
15 |
16 | for _, block := range body.Blocks {
17 | if block.Type != "resource" || len(block.Labels) < 2 { // skip anything without 2 labels eg "aws_s3_bucket" and "this"
18 | continue
19 | }
20 |
21 | resourceType := block.Labels[0] // aws_s3_bucket
22 | resourceName := block.Labels[1] // this
23 |
24 | if !strings.HasPrefix(resourceType, "aws_") {
25 | continue
26 | }
27 |
28 | isTaggable := true // assume resource is taggable, by default
29 | if taggable != nil {
30 | var found bool
31 | isTaggable, found = taggable[resourceType]
32 | if !found {
33 | isTaggable = true // if not found, assume resource is taggable
34 | // isTaggable = false
35 | // log.Printf("Warning: Resource type %s not found in provider schema. Assuming not taggable.", resourceType) //todo
36 | }
37 | } else {
38 | }
39 |
40 | if !isTaggable {
41 | // log.Printf("Skipping non-taggable resource type: %s", resourceType)
42 | continue
43 | }
44 |
45 | providerID := getResourceProvider(block, caseInsensitive)
46 | providerEvalTags := defaultTags.LiteralTags[providerID]
47 | if providerEvalTags == nil {
48 | providerEvalTags = make(shared.TagMap)
49 | }
50 |
51 | resourceEvalTags := findTags(block, tfCtx, caseInsensitive)
52 | effectiveTags := mergeTags(providerEvalTags, resourceEvalTags)
53 |
54 | missingTags := shared.FilterMissingTags(requiredTags, effectiveTags, caseInsensitive)
55 | if len(missingTags) > 0 {
56 | violation := Violation{
57 | resourceType: resourceType,
58 | resourceName: resourceName,
59 | line: block.DefRange().Start.Line,
60 | missingTags: missingTags,
61 | }
62 | if skipAll || SkipResource(block, fileLines) {
63 | violation.skip = true
64 | }
65 | violations = append(violations, violation)
66 | }
67 | }
68 | return violations
69 | }
70 |
71 | // getResourceProvider determines the provider for a resource block
72 | func getResourceProvider(block *hclsyntax.Block, caseInsensitive bool) string {
73 | if attr, ok := block.Body.Attributes["provider"]; ok {
74 |
75 | // provider is a literal string ("aws")
76 | val, diags := attr.Expr.Value(nil)
77 | if !diags.HasErrors() {
78 | s := val.AsString()
79 | if caseInsensitive {
80 | s = strings.ToLower(s)
81 | }
82 | return s
83 | }
84 | // provider is not a literal string ("aws.west")
85 | s := traversalToString(attr.Expr, caseInsensitive)
86 | if s != "" {
87 | return s
88 | }
89 | }
90 |
91 | // no explicit provider, return default provider
92 | defaultProvider := "aws"
93 | if caseInsensitive {
94 | defaultProvider = strings.ToLower(defaultProvider)
95 | }
96 | return defaultProvider
97 | }
98 |
99 | // findTags returns tag map from a resource block (with extractTags), if it has tags
100 | func findTags(block *hclsyntax.Block, tfCtx *TerraformContext, caseInsensitive bool) shared.TagMap {
101 | evalTags := make(shared.TagMap)
102 | if attr, exists := block.Body.Attributes["tags"]; exists {
103 |
104 | childCtx := tfCtx.EvalContext.NewChild()
105 | if childCtx.Variables == nil {
106 | childCtx.Variables = make(map[string]cty.Value)
107 | }
108 | childCtx.Variables["each"] = cty.ObjectVal(map[string]cty.Value{
109 | "key": cty.StringVal(""),
110 | "value": cty.StringVal(""),
111 | })
112 | tagsVal, diags := attr.Expr.Value(childCtx)
113 |
114 | if diags.HasErrors() {
115 | log.Printf("Error evaluating tags for resource %s.%s: %v", block.Labels[0], block.Labels[1], diags)
116 | return evalTags
117 | }
118 |
119 | if !tagsVal.Type().IsObjectType() && !tagsVal.Type().IsMapType() {
120 | return evalTags
121 | }
122 | if tagsVal.IsNull() {
123 | return evalTags
124 | }
125 |
126 | for key, val := range tagsVal.AsValueMap() {
127 | var valStr string
128 | if val.IsNull() {
129 | valStr = ""
130 | } else if val.Type() == cty.String {
131 | valStr = val.AsString()
132 | } else {
133 | strResult, err := convertCtyValueToString(val)
134 | if err != nil {
135 | valStr = ""
136 | } else {
137 | valStr = strResult
138 | }
139 | }
140 |
141 | effectiveKey := key
142 | if caseInsensitive {
143 | effectiveKey = strings.ToLower(key)
144 | }
145 | evalTags[effectiveKey] = []string{valStr}
146 | }
147 | }
148 | return evalTags
149 | }
150 |
--------------------------------------------------------------------------------
/internal/terraform/process.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "log"
5 | "os"
6 | "path/filepath"
7 | "strings"
8 |
9 | "github.com/hashicorp/hcl/v2"
10 | "github.com/hashicorp/hcl/v2/hclparse"
11 | "github.com/hashicorp/hcl/v2/hclsyntax"
12 | "github.com/jakebark/tag-nag/internal/config"
13 | "github.com/jakebark/tag-nag/internal/shared"
14 | "github.com/zclconf/go-cty/cty"
15 | "github.com/zclconf/go-cty/cty/function"
16 | )
17 |
18 | type tfFile struct {
19 | path string
20 | info os.FileInfo
21 | }
22 |
23 | // ProcessDirectory walks all terraform files in directory
24 | func ProcessDirectory(dirPath string, requiredTags map[string][]string, caseInsensitive bool, skip []string) int {
25 | hasFiles, err := scan(dirPath)
26 | if err != nil {
27 | return 0
28 | }
29 | if !hasFiles {
30 | return 0
31 | }
32 |
33 | // log.Println("Terraform files found\n")
34 | var totalViolations int
35 |
36 | taggable := loadTaggableResources("registry.terraform.io/hashicorp/aws")
37 | if taggable == nil {
38 | // log.Printf("Warning: Failed to load Terraform AWS Provider\nRun 'terraform init' to fix\n")
39 | // log.Printf("Continuing with limited features ... \n ")
40 | }
41 |
42 | tfCtx, err := buildTagContext(dirPath)
43 | if err != nil {
44 | tfCtx = &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value), Functions: make(map[string]function.Function)}}
45 | }
46 |
47 | // single directory walk
48 | tfFiles, err := collectFiles(dirPath, skip)
49 | if err != nil {
50 | log.Printf("Error scanning directory %q: %v\n", dirPath, err)
51 | return 0
52 | }
53 |
54 | if len(tfFiles) == 0 {
55 | return 0
56 | }
57 |
58 | // extract default tags from all files
59 | defaultTags := processDefaultTags(tfFiles, tfCtx, caseInsensitive)
60 |
61 | // process resources for tag violations
62 | for _, tf := range tfFiles {
63 | violations := processFile(tf.path, requiredTags, &defaultTags, tfCtx, caseInsensitive, taggable)
64 | totalViolations += len(violations)
65 | }
66 |
67 | return totalViolations
68 | }
69 |
70 | // collectFiles identifies all elligible terraform files
71 | func collectFiles(dirPath string, skip []string) ([]tfFile, error) {
72 | var tfFiles []tfFile
73 |
74 | err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
75 | if err != nil {
76 | return err
77 | }
78 |
79 | if skipDirectories(path, info, skip) {
80 | if info.IsDir() {
81 | return filepath.SkipDir
82 | }
83 | return nil
84 | }
85 |
86 | if !info.IsDir() && filepath.Ext(path) == ".tf" {
87 | tfFiles = append(tfFiles, tfFile{path: path, info: info})
88 | }
89 | return nil
90 | })
91 |
92 | return tfFiles, err
93 | }
94 |
95 | // skipDir identifies directories to ignore
96 | func skipDirectories(path string, info os.FileInfo, skip []string) bool {
97 | // user-defined skip paths
98 | for _, skipped := range skip {
99 | if strings.HasPrefix(path, skipped) {
100 | return true
101 | }
102 | }
103 |
104 | // default skipped directories eg .git
105 | if info.IsDir() {
106 | dirName := info.Name()
107 | for _, skippedDir := range config.SkippedDirs {
108 | if dirName == skippedDir {
109 | return true
110 | }
111 | }
112 | }
113 |
114 | return false
115 | }
116 |
117 | // processFile parses files looking for resources
118 | func processFile(filePath string, requiredTags shared.TagMap, defaultTags *DefaultTags, tfCtx *TerraformContext, caseInsensitive bool, taggable map[string]bool) []Violation {
119 | data, err := os.ReadFile(filePath)
120 | if err != nil {
121 | log.Printf("Error reading %s: %v\n", filePath, err)
122 | return nil
123 | }
124 | content := string(data)
125 | lines := strings.Split(content, "\n")
126 |
127 | skipAll := strings.Contains(content, config.TagNagIgnoreAll)
128 |
129 | parser := hclparse.NewParser()
130 | file, diagnostics := parser.ParseHCLFile(filePath)
131 |
132 | if diagnostics.HasErrors() {
133 | log.Printf("Error parsing %s: %v\n", filePath, diagnostics)
134 | return nil
135 | }
136 |
137 | syntaxBody, ok := file.Body.(*hclsyntax.Body)
138 | if !ok {
139 | log.Printf("Parsing failed for %s\n", filePath)
140 | return nil
141 | }
142 |
143 | violations := checkResourcesForTags(syntaxBody, requiredTags, defaultTags, tfCtx, caseInsensitive, lines, skipAll, taggable)
144 |
145 | if len(violations) > 0 {
146 | log.Printf("\nViolation(s) in %s\n", filePath)
147 | for _, v := range violations {
148 | if v.skip {
149 | log.Printf(" %d: %s \"%s\" skipped\n", v.line, v.resourceType, v.resourceName)
150 | } else {
151 | log.Printf(" %d: %s \"%s\" 🏷️ Missing tags: %s\n", v.line, v.resourceType, v.resourceName, strings.Join(v.missingTags, ", "))
152 | }
153 | }
154 | }
155 |
156 | var filteredViolations []Violation
157 | for _, v := range violations {
158 | if !v.skip {
159 | filteredViolations = append(filteredViolations, v)
160 | }
161 | }
162 | return filteredViolations
163 | }
164 |
--------------------------------------------------------------------------------
/internal/inputs/inputs.go:
--------------------------------------------------------------------------------
1 | package inputs
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "strings"
7 |
8 | "github.com/jakebark/tag-nag/internal/shared"
9 | "github.com/spf13/pflag"
10 | )
11 |
12 | type UserInput struct {
13 | Directory string
14 | RequiredTags shared.TagMap
15 | CaseInsensitive bool
16 | DryRun bool
17 | CfnSpecPath string
18 | Skip []string
19 | }
20 |
21 | // ParseFlags returns pased CLI flags and arguments
22 | func ParseFlags() UserInput {
23 | var caseInsensitive bool
24 | var dryRun bool
25 | var tags string
26 | var cfnSpecPath string
27 | var skip string
28 |
29 | pflag.BoolVarP(&caseInsensitive, "case-insensitive", "c", false, "Make tag checks non-case-sensitive")
30 | pflag.BoolVarP(&dryRun, "dry-run", "d", false, "Dry run tag:nag without triggering exit(1) code")
31 | pflag.StringVar(&tags, "tags", "", "Comma-separated list of required tag keys (e.g., 'Owner,Environment[Dev,Prod]')")
32 | pflag.StringVar(&cfnSpecPath, "cfn-spec", "", "Optional path to CloudFormationResourceSpecification.json)")
33 | pflag.StringVarP(&skip, "skip", "s", "", "Comma-separated list of files or directories to skip")
34 | pflag.Parse()
35 |
36 | if pflag.NArg() < 1 {
37 | log.Fatal("Error: specify a directory or file to scan")
38 | }
39 |
40 | // try config file if no tags provided
41 | if tags == "" {
42 | configFile, err := FindAndLoadConfigFile()
43 | if err != nil {
44 | log.Fatalf("Error loading config: %v", err)
45 | }
46 | if configFile != nil {
47 | return UserInput{
48 | Directory: pflag.Arg(0),
49 | RequiredTags: configFile.convertToTagMap(),
50 | CaseInsensitive: configFile.Settings.CaseInsensitive,
51 | DryRun: configFile.Settings.DryRun,
52 | CfnSpecPath: configFile.Settings.CfnSpec,
53 | Skip: configFile.Skip,
54 | }
55 | }
56 | log.Fatal("Error: specify required tags using --tags or create a .tag-nag.yml config file")
57 | }
58 |
59 | parsedTags, err := parseTags(tags)
60 | if err != nil {
61 | log.Fatalf("Error parsing tags: %v", err)
62 | }
63 |
64 | var skipPaths []string
65 | if skip != "" {
66 | skipPaths = strings.Split(skip, ",")
67 | for i := range skipPaths {
68 | skipPaths[i] = strings.TrimSpace(skipPaths[i])
69 | }
70 | }
71 |
72 | return UserInput{
73 | Directory: pflag.Arg(0),
74 | RequiredTags: parsedTags,
75 | CaseInsensitive: caseInsensitive,
76 | DryRun: dryRun,
77 | CfnSpecPath: cfnSpecPath,
78 | Skip: skipPaths,
79 | }
80 | }
81 |
82 | // parses tag input components
83 | func parseTags(input string) (shared.TagMap, error) {
84 | tagMap := make(shared.TagMap)
85 | pairs := splitTags(input)
86 | for _, pair := range pairs {
87 | trimmed := strings.TrimSpace(pair)
88 | if trimmed == "" {
89 | continue
90 | }
91 |
92 | key, values, err := parseTag(trimmed)
93 | if err != nil {
94 | return nil, fmt.Errorf("failed to parse tag component '%s': %w", trimmed, err)
95 | }
96 | tagMap[key] = values
97 | }
98 | return tagMap, nil
99 | }
100 |
101 | // parses tag keys and values
102 | func parseTag(tagComponent string) (key string, values []string, err error) {
103 | trimmed := strings.TrimSpace(tagComponent)
104 | if trimmed == "" {
105 | return "", nil, fmt.Errorf("empty tag component")
106 | }
107 |
108 | // key and value
109 | openBracketIdx := strings.Index(trimmed, "[")
110 | if openBracketIdx != -1 {
111 | if !strings.HasSuffix(trimmed, "]") {
112 | return "", nil, fmt.Errorf("invalid tag format: %s. Expected closing ']'", trimmed)
113 | }
114 |
115 | key = strings.TrimSpace(trimmed[:openBracketIdx])
116 | if key == "" {
117 | return "", nil, fmt.Errorf("empty key in bracket format: %s", trimmed)
118 | }
119 |
120 | valuesStr := trimmed[openBracketIdx+1 : len(trimmed)-1]
121 | if valuesStr == "" {
122 | return key, []string{}, nil
123 | }
124 |
125 | valParts := strings.Split(valuesStr, ",")
126 | for _, v := range valParts {
127 | trimmedVal := strings.TrimSpace(v)
128 | if trimmedVal != "" {
129 | values = append(values, trimmedVal)
130 | }
131 | }
132 | return key, values, nil
133 | }
134 |
135 | // key only
136 | if strings.Contains(trimmed, "[") || strings.Contains(trimmed, "]") {
137 | return "", nil, fmt.Errorf("invalid tag format: %s. Contains '[' or ']' without matching pair or value definition", trimmed)
138 | }
139 | return trimmed, []string{}, nil
140 | }
141 |
142 | // splitTags splits the input string on commas outside of brackets
143 | // to fix the [a,b,c] issue
144 | func splitTags(input string) []string {
145 | var parts []string
146 | start := 0
147 | depth := 0
148 | for i, r := range input {
149 | switch r {
150 | case '[':
151 | depth++
152 | case ']':
153 | if depth > 0 {
154 | depth--
155 | }
156 | case ',':
157 | if depth == 0 {
158 | parts = append(parts, strings.TrimSpace(input[start:i]))
159 | start = i + 1
160 | }
161 | }
162 | }
163 | parts = append(parts, strings.TrimSpace(input[start:]))
164 | return parts
165 | }
166 |
--------------------------------------------------------------------------------
/internal/terraform/helpers_test.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 |
7 | "github.com/hashicorp/hcl/v2"
8 | "github.com/hashicorp/hcl/v2/hclsyntax"
9 | "github.com/jakebark/tag-nag/internal/shared"
10 | "github.com/zclconf/go-cty/cty"
11 | )
12 |
13 | func TestTraversalToString(t *testing.T) {
14 | testCases := []struct {
15 | name string
16 | hclInput string
17 | caseInsensitive bool
18 | expected string
19 | }{
20 | {
21 | name: "literal",
22 | hclInput: "Owner",
23 | caseInsensitive: false,
24 | expected: "Owner",
25 | },
26 | {
27 | name: "literal, case insensitive",
28 | hclInput: "Owner",
29 | caseInsensitive: true,
30 | expected: "owner",
31 | },
32 | {
33 | name: "traversal",
34 | hclInput: "local.network.subnets[0].id",
35 | caseInsensitive: false,
36 | expected: "local.network.subnets.id",
37 | },
38 | }
39 |
40 | for _, tc := range testCases {
41 | t.Run(tc.name, func(t *testing.T) {
42 | // Parse the HCL expression string
43 | expr, diags := hclsyntax.ParseExpression([]byte(tc.hclInput), tc.name+".tf", hcl.Pos{Line: 1, Column: 1})
44 | if diags.HasErrors() {
45 | t.Fatalf("Failed to parse expression %q: %v", tc.hclInput, diags)
46 | }
47 |
48 | actual := traversalToString(expr, tc.caseInsensitive)
49 | if actual != tc.expected {
50 | t.Errorf("traversalToString(%q, %v) = %q; want %q", tc.hclInput, tc.caseInsensitive, actual, tc.expected)
51 | }
52 | })
53 | }
54 | }
55 |
56 | func TestMergeTags(t *testing.T) {
57 | testCases := []struct {
58 | name string
59 | inputs []shared.TagMap
60 | expected shared.TagMap
61 | }{
62 | {
63 | name: "empty",
64 | inputs: []shared.TagMap{},
65 | expected: shared.TagMap{},
66 | },
67 | {
68 | name: "key",
69 | inputs: []shared.TagMap{
70 | {"Environment": {}},
71 | },
72 | expected: shared.TagMap{"Environment": {}},
73 | },
74 | {
75 | name: "key and value",
76 | inputs: []shared.TagMap{{"Environment": {"Dev"}}},
77 | expected: shared.TagMap{"Environment": {"Dev"}},
78 | },
79 | {
80 | name: "multiple keys and values",
81 | inputs: []shared.TagMap{
82 | {"Environment": {"Dev"}},
83 | {"Owner": {"Prod"}},
84 | },
85 | expected: shared.TagMap{"Environment": {"Dev"}, "Owner": {"Prod"}},
86 | },
87 | {
88 | name: "overlapping values, last wins",
89 | inputs: []shared.TagMap{
90 | {"Environment": {"Dev"}, "Owner": {"jakebark"}},
91 | {"Owner": {"Jake"}, "CostCenter": {"C-01"}},
92 | },
93 | expected: shared.TagMap{"Environment": {"Dev"}, "Owner": {"Jake"}, "CostCenter": {"C-01"}},
94 | },
95 | {
96 | name: "overlapping empty value, last wins",
97 | inputs: []shared.TagMap{
98 | {"Environment": {"Dev"}},
99 | {"Environment": {}},
100 | },
101 | expected: shared.TagMap{"Environment": {}},
102 | },
103 | }
104 |
105 | for _, tc := range testCases {
106 | t.Run(tc.name, func(t *testing.T) {
107 | actual := mergeTags(tc.inputs...)
108 | if !reflect.DeepEqual(actual, tc.expected) {
109 | t.Errorf("mergeTags() = %v; want %v", actual, tc.expected)
110 | }
111 | })
112 | }
113 | }
114 |
115 | func TestConvertCtyValueToString(t *testing.T) {
116 | tests := []struct {
117 | name string
118 | input cty.Value
119 | want string
120 | wantErr bool
121 | }{
122 | {
123 | name: "string",
124 | input: cty.StringVal("hello world"),
125 | want: "hello world",
126 | },
127 | {
128 | name: "empty string",
129 | input: cty.StringVal(""),
130 | want: "",
131 | },
132 | {
133 | name: "number",
134 | input: cty.NumberIntVal(123),
135 | want: "123",
136 | },
137 | {
138 | name: "float",
139 | input: cty.NumberFloatVal(123.45),
140 | want: "123.45",
141 | },
142 | {
143 | name: "bool, true",
144 | input: cty.True,
145 | want: "true",
146 | },
147 | {
148 | name: "bool, false",
149 | input: cty.False,
150 | want: "false",
151 | },
152 | {
153 | name: "null",
154 | input: cty.NullVal(cty.String),
155 | want: "",
156 | },
157 | {
158 | name: "unknown value",
159 | input: cty.UnknownVal(cty.String),
160 | wantErr: true,
161 | },
162 | {
163 | name: "list",
164 | input: cty.ListVal([]cty.Value{cty.StringVal("a"), cty.StringVal("b")}),
165 | want: `["a","b"]`,
166 | },
167 | {
168 | name: "map",
169 | input: cty.MapVal(map[string]cty.Value{"key": cty.StringVal("value")}),
170 | want: `{"key":"value"}`,
171 | },
172 | }
173 |
174 | for _, tc := range tests {
175 | t.Run(tc.name, func(t *testing.T) {
176 | got, err := convertCtyValueToString(tc.input)
177 | if (err != nil) != tc.wantErr {
178 | t.Fatalf("convertCtyValueToString() error = %v, wantErr %v", err, tc.wantErr)
179 | }
180 | if !tc.wantErr && got != tc.want {
181 | t.Errorf("convertCtyValueToString() = %v, want %v", got, tc.want)
182 | }
183 | })
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/internal/shared/helpers_test.go:
--------------------------------------------------------------------------------
1 | package shared
2 |
3 | import (
4 | "reflect"
5 | "sort"
6 | "testing"
7 | )
8 |
9 | func TestFilterMissingTags(t *testing.T) {
10 | testCases := []struct {
11 | name string
12 | requiredTags TagMap
13 | effectiveTags TagMap
14 | caseInsensitive bool
15 | expectedMissing []string
16 | }{
17 | {
18 | name: "tags present",
19 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod", "Dev"}},
20 | effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Prod"}},
21 | caseInsensitive: false,
22 | expectedMissing: nil,
23 | },
24 | {
25 | name: "missing key",
26 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}},
27 | effectiveTags: TagMap{"Env": {"Prod"}},
28 | caseInsensitive: false,
29 | expectedMissing: []string{"Owner"},
30 | },
31 | {
32 | name: "wrong value",
33 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}},
34 | effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Dev"}},
35 | caseInsensitive: false,
36 | expectedMissing: []string{"Env[Prod]"},
37 | },
38 | {
39 | name: "missing key and value",
40 | requiredTags: TagMap{"Env": {"Prod"}},
41 | effectiveTags: TagMap{"Owner": {"a"}},
42 | caseInsensitive: false,
43 | expectedMissing: []string{"Env[Prod]"},
44 | },
45 | {
46 | name: "tags present, case insensitive",
47 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod", "Dev"}},
48 | effectiveTags: TagMap{"owner": {"a"}, "env": {"prod"}},
49 | caseInsensitive: true,
50 | expectedMissing: nil,
51 | },
52 | {
53 | name: "missing key, case insensitive",
54 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}},
55 | effectiveTags: TagMap{"env": {"Prod"}},
56 | caseInsensitive: true,
57 | expectedMissing: []string{"Owner"},
58 | },
59 | {
60 | name: "wrong value, case insensitive",
61 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}},
62 | effectiveTags: TagMap{"owner": {"a"}, "env": {"Dev"}},
63 | caseInsensitive: true,
64 | expectedMissing: []string{"Env[Prod]"},
65 | },
66 | {
67 | name: "missing key and value, case insensitive",
68 | requiredTags: TagMap{"Env": {"Prod"}},
69 | effectiveTags: TagMap{"owner": {"a"}},
70 | caseInsensitive: true,
71 | expectedMissing: []string{"Env[Prod]"},
72 | },
73 | {
74 | name: "no required tags",
75 | requiredTags: TagMap{},
76 | effectiveTags: TagMap{"Owner": {"a"}, "Env": {"Dev"}},
77 | caseInsensitive: false,
78 | expectedMissing: nil,
79 | },
80 | {
81 | name: "no tags",
82 | requiredTags: TagMap{"Owner": {}, "Env": {"Prod"}},
83 | effectiveTags: TagMap{},
84 | caseInsensitive: false,
85 | expectedMissing: []string{"Owner", "Env[Prod]"},
86 | },
87 | {
88 | name: "multiple values required, one present",
89 | requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}},
90 | effectiveTags: TagMap{"Region": {"us-west-2"}},
91 | caseInsensitive: false,
92 | expectedMissing: nil,
93 | },
94 | {
95 | name: "multiple values required, none present",
96 | requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}},
97 | effectiveTags: TagMap{"Region": {"eu-central-1"}},
98 | caseInsensitive: false,
99 | expectedMissing: []string{"Region[us-east-1,us-west-2]"},
100 | },
101 | {
102 | name: "multiple values required, one present, case insensitive",
103 | requiredTags: TagMap{"Env": {"Prod", "Dev"}},
104 | effectiveTags: TagMap{"Env": {"prod"}},
105 | caseInsensitive: true,
106 | expectedMissing: nil,
107 | },
108 | {
109 | name: "multiple values required, none present, case insensitive",
110 | requiredTags: TagMap{"Region": {"us-east-1", "us-west-2"}},
111 | effectiveTags: TagMap{"region": {"eu-central-1"}},
112 | caseInsensitive: true,
113 | expectedMissing: []string{"Region[us-east-1,us-west-2]"},
114 | },
115 | {
116 | name: "multiple values required, multiple values present",
117 | requiredTags: TagMap{"Env": {"Prod", "Dev"}},
118 | effectiveTags: TagMap{"Env": {"Prod", "Stage"}},
119 | caseInsensitive: false,
120 | expectedMissing: nil,
121 | },
122 | {
123 | name: "multiple values present, none match",
124 | requiredTags: TagMap{"Env": {"Prod"}},
125 | effectiveTags: TagMap{"Env": {"Dev", "Stage"}},
126 | caseInsensitive: false,
127 | expectedMissing: []string{"Env[Prod]"},
128 | },
129 | }
130 |
131 | for _, tc := range testCases {
132 | t.Run(tc.name, func(t *testing.T) {
133 | actual := FilterMissingTags(tc.requiredTags, tc.effectiveTags, tc.caseInsensitive)
134 |
135 | if actual != nil && tc.expectedMissing != nil {
136 | sort.Strings(actual)
137 | sort.Strings(tc.expectedMissing)
138 | }
139 |
140 | if !reflect.DeepEqual(actual, tc.expectedMissing) {
141 | t.Errorf("FilterMissingTags() = %#v; want %#v", actual, tc.expectedMissing)
142 | }
143 | })
144 | }
145 | }
146 |
--------------------------------------------------------------------------------
/internal/terraform/resources_test.go:
--------------------------------------------------------------------------------
1 | package terraform
2 |
3 | import (
4 | "sort"
5 | "strings"
6 | "testing"
7 |
8 | "github.com/google/go-cmp/cmp"
9 | "github.com/google/go-cmp/cmp/cmpopts"
10 | "github.com/hashicorp/hcl/v2"
11 | "github.com/hashicorp/hcl/v2/hclparse"
12 | "github.com/hashicorp/hcl/v2/hclsyntax"
13 | "github.com/jakebark/tag-nag/internal/shared"
14 | "github.com/zclconf/go-cty/cty"
15 | )
16 |
17 | func TestCheckResourcesForTags_Taggability(t *testing.T) {
18 | parser := hclparse.NewParser()
19 |
20 | tfCode := `
21 | resource "aws_s3_bucket" "taggable_bucket" {
22 | tags = {
23 | Owner = "test-user" // Missing Environment
24 | }
25 | }
26 |
27 | resource "aws_kms_alias" "non_taggable_alias" {
28 | alias_name = "alias/my-key-alias"
29 | target_key_id = "some-key-id"
30 | }
31 |
32 | resource "aws_instance" "another_taggable" {
33 | ami = "ami-12345"
34 | instance_type = "t2.micro"
35 | tags = {
36 | Owner = "test-user"
37 | Environment = "dev"
38 | }
39 | }
40 |
41 | resource "aws_route53_zone" "unknown_in_schema_should_be_checked" {
42 | name = "example.com"
43 | # Missing all tags
44 | }
45 | `
46 |
47 | file, diags := parser.ParseHCL([]byte(tfCode), "test.tf")
48 | if diags.HasErrors() {
49 | t.Fatalf("Failed to parse test HCL: %v", diags)
50 | }
51 |
52 | body, ok := file.Body.(*hclsyntax.Body)
53 | if !ok {
54 | t.Fatalf("Could not get HCL syntax body")
55 | }
56 |
57 | requiredTags := shared.TagMap{
58 | "Owner": {},
59 | "Environment": {},
60 | }
61 | lines := strings.Split(tfCode, "\n")
62 |
63 | t.Run("With Taggability Filter", func(t *testing.T) {
64 | taggableMap := map[string]bool{
65 | "aws_s3_bucket": true,
66 | "aws_kms_alias": false,
67 | "aws_instance": true,
68 | "aws_iam_user": false, // Example of another non-taggable not in the HCL
69 | "aws_route53_zone": true, // Explicitly mark as taggable for test
70 | }
71 |
72 | mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}
73 | mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}
74 |
75 | violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap)
76 |
77 | expectedViolations := []Violation{
78 | {resourceType: "aws_s3_bucket", resourceName: "taggable_bucket", line: 2, missingTags: []string{"Environment"}},
79 | {resourceType: "aws_route53_zone", resourceName: "unknown_in_schema_should_be_checked", line: 19, missingTags: []string{"Environment", "Owner"}}, // Order might vary
80 | }
81 |
82 | sortViolations(violations)
83 | sortViolations(expectedViolations) // Sort missing tags within each violation for stable comparison
84 |
85 | if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(Violation{})); diff != "" {
86 | t.Errorf("checkResourcesForTags with filter mismatch (-want +got):\n%s", diff)
87 | }
88 | })
89 |
90 | t.Run("Without Taggability Filter (nil map)", func(t *testing.T) {
91 | taggableMap := map[string]bool(nil)
92 |
93 | mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}
94 | mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}
95 |
96 | violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap)
97 |
98 | expectedViolations := []Violation{
99 | {resourceType: "aws_s3_bucket", resourceName: "taggable_bucket", line: 2, missingTags: []string{"Environment"}},
100 | {resourceType: "aws_kms_alias", resourceName: "non_taggable_alias", line: 8, missingTags: []string{"Environment", "Owner"}},
101 | {resourceType: "aws_route53_zone", resourceName: "unknown_in_schema_should_be_checked", line: 19, missingTags: []string{"Environment", "Owner"}},
102 | }
103 | sortViolations(violations)
104 | sortViolations(expectedViolations)
105 |
106 | if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(Violation{})); diff != "" {
107 | t.Errorf("checkResourcesForTags without filter mismatch (-want +got):\n%s", diff)
108 | }
109 | })
110 |
111 | t.Run("With Taggability Filter - resource type not in map (should assume taggable)", func(t *testing.T) {
112 | // aws_route53_zone is NOT in this specific taggableMap
113 | taggableMap := map[string]bool{
114 | "aws_s3_bucket": true,
115 | "aws_kms_alias": false,
116 | "aws_instance": true,
117 | }
118 |
119 | mockCtx := &TerraformContext{EvalContext: &hcl.EvalContext{Variables: make(map[string]cty.Value)}}
120 | mockDefaults := &DefaultTags{LiteralTags: make(map[string]shared.TagMap)}
121 |
122 | violations := checkResourcesForTags(body, requiredTags, mockDefaults, mockCtx, false, lines, false, taggableMap)
123 |
124 | expectedViolations := []Violation{
125 | {resourceType: "aws_s3_bucket", resourceName: "taggable_bucket", line: 2, missingTags: []string{"Environment"}},
126 | // aws_route53_zone is assumed taggable as it's not in the map with a 'false' entry
127 | {resourceType: "aws_route53_zone", resourceName: "unknown_in_schema_should_be_checked", line: 19, missingTags: []string{"Environment", "Owner"}},
128 | }
129 |
130 | sortViolations(violations)
131 | sortViolations(expectedViolations)
132 |
133 | if diff := cmp.Diff(expectedViolations, violations, cmpopts.IgnoreUnexported(Violation{})); diff != "" {
134 | t.Errorf("checkResourcesForTags with incomplete filter mismatch (-want +got):\n%s", diff)
135 | }
136 | })
137 | }
138 |
139 | // Helper to sort violations for consistent comparison
140 | func sortViolations(violations []Violation) {
141 | for i := range violations {
142 | sort.Strings(violations[i].missingTags)
143 | }
144 | sort.Slice(violations, func(i, j int) bool {
145 | if violations[i].resourceType != violations[j].resourceType {
146 | return violations[i].resourceType < violations[j].resourceType
147 | }
148 | return violations[i].resourceName < violations[j].resourceName
149 | })
150 | }
151 |
--------------------------------------------------------------------------------
/internal/inputs/loader_test.go:
--------------------------------------------------------------------------------
1 | package inputs
2 |
3 | import (
4 | "os"
5 | "reflect"
6 | "testing"
7 |
8 | "github.com/jakebark/tag-nag/internal/shared"
9 | )
10 |
11 | func TestProcessConfigFile(t *testing.T) {
12 | testCases := []struct {
13 | name string
14 | configFile string
15 | expectedError bool
16 | expectedTags int
17 | expectedOwner bool
18 | expectedEnvValues []string
19 | expectedSettings Settings
20 | expectedSkips []string
21 | }{
22 | {
23 | name: "tag keys",
24 | configFile: "../../testdata/config/tag_keys.yml",
25 | expectedError: false,
26 | expectedTags: 2,
27 | expectedOwner: true,
28 | expectedSettings: Settings{
29 | CaseInsensitive: false,
30 | DryRun: false,
31 | CfnSpec: "",
32 | },
33 | expectedSkips: []string{},
34 | },
35 | {
36 | name: "tag values",
37 | configFile: "../../testdata/config/tag_values.yml",
38 | expectedError: false,
39 | expectedTags: 3,
40 | expectedOwner: true,
41 | expectedEnvValues: []string{"Dev", "Test", "Prod"},
42 | expectedSettings: Settings{
43 | CaseInsensitive: false,
44 | DryRun: false,
45 | CfnSpec: "",
46 | },
47 | expectedSkips: []string{},
48 | },
49 | {
50 | name: "full config",
51 | configFile: "../../testdata/config/full_config.yml",
52 | expectedError: false,
53 | expectedTags: 3,
54 | expectedOwner: true,
55 | expectedSettings: Settings{
56 | CaseInsensitive: true,
57 | DryRun: false,
58 | CfnSpec: "/path/to/spec.json",
59 | },
60 | expectedSkips: []string{"*.tmp", ".terraform", "test-data/**"},
61 | },
62 | {
63 | name: "empty settings",
64 | configFile: "../../testdata/config/empty_settings.yml",
65 | expectedError: false,
66 | expectedTags: 1,
67 | expectedOwner: true,
68 | expectedSettings: Settings{
69 | CaseInsensitive: false,
70 | DryRun: false,
71 | CfnSpec: "",
72 | },
73 | expectedSkips: []string{},
74 | },
75 | {
76 | name: "yaml extension",
77 | configFile: "../../testdata/config/yaml_extension.yaml",
78 | expectedError: false,
79 | expectedTags: 1,
80 | expectedOwner: true,
81 | expectedSettings: Settings{
82 | CaseInsensitive: false,
83 | DryRun: false,
84 | CfnSpec: "",
85 | },
86 | expectedSkips: []string{},
87 | },
88 | {
89 | name: "blank config",
90 | configFile: "../../testdata/config/blank_config.yml",
91 | expectedError: false,
92 | expectedTags: 0,
93 | expectedOwner: false,
94 | expectedSettings: Settings{
95 | CaseInsensitive: false,
96 | DryRun: false,
97 | CfnSpec: "",
98 | },
99 | expectedSkips: []string{},
100 | },
101 | {
102 | name: "invalid syntax",
103 | configFile: "../../testdata/config/invalid_syntax.yml",
104 | expectedError: true,
105 | },
106 | {
107 | name: "missing tags",
108 | configFile: "../../testdata/config/missing_tags.yml",
109 | expectedError: false,
110 | expectedTags: 0,
111 | expectedOwner: false,
112 | expectedSettings: Settings{
113 | CaseInsensitive: false,
114 | DryRun: true,
115 | CfnSpec: "",
116 | },
117 | expectedSkips: []string{"*.tmp"},
118 | },
119 | {
120 | name: "invalid structure",
121 | configFile: "../../testdata/config/invalid_structure.yml",
122 | expectedError: true,
123 | },
124 | {
125 | name: "invalid tag value array",
126 | configFile: "../../testdata/config/tag_array.yml",
127 | expectedError: true,
128 | },
129 | {
130 | name: "no file",
131 | configFile: "../../testdata/config/does-not-exist.yml",
132 | expectedError: true,
133 | },
134 | }
135 |
136 | for _, tc := range testCases {
137 | t.Run(tc.name, func(t *testing.T) {
138 | config, err := processConfigFile(tc.configFile)
139 |
140 | // Check error expectation
141 | if tc.expectedError {
142 | if err == nil {
143 | t.Errorf("Expected error but got none")
144 | }
145 | return
146 | }
147 |
148 | if err != nil {
149 | t.Errorf("Unexpected error: %v", err)
150 | return
151 | }
152 |
153 | if config == nil {
154 | t.Errorf("Expected config but got nil")
155 | return
156 | }
157 |
158 | // Check number of tags
159 | if len(config.Tags) != tc.expectedTags {
160 | t.Errorf("Expected %d tags, got %d", tc.expectedTags, len(config.Tags))
161 | }
162 |
163 | // Check if Owner tag exists
164 | hasOwner := false
165 | var envValues []string
166 | for _, tag := range config.Tags {
167 | if tag.Key == "Owner" {
168 | hasOwner = true
169 | }
170 | if tag.Key == "Environment" {
171 | envValues = tag.Values
172 | }
173 | }
174 |
175 | if hasOwner != tc.expectedOwner {
176 | t.Errorf("Expected Owner tag: %v, got: %v", tc.expectedOwner, hasOwner)
177 | }
178 |
179 | // Check Environment values if specified
180 | if tc.expectedEnvValues != nil {
181 | if !reflect.DeepEqual(envValues, tc.expectedEnvValues) {
182 | t.Errorf("Expected Environment values %v, got %v", tc.expectedEnvValues, envValues)
183 | }
184 | }
185 |
186 | // Check settings
187 | if config.Settings != tc.expectedSettings {
188 | t.Errorf("Expected settings %+v, got %+v", tc.expectedSettings, config.Settings)
189 | }
190 |
191 | // Check skip patterns (handle nil vs empty slice)
192 | configSkips := config.Skip
193 | if configSkips == nil {
194 | configSkips = []string{}
195 | }
196 | if !reflect.DeepEqual(configSkips, tc.expectedSkips) {
197 | t.Errorf("Expected skip patterns %v, got %v", tc.expectedSkips, configSkips)
198 | }
199 | })
200 | }
201 | }
202 |
203 | func TestFindAndLoadConfigFile(t *testing.T) {
204 | // Save current directory
205 | originalDir, err := os.Getwd()
206 | if err != nil {
207 | t.Fatalf("Failed to get current directory: %v", err)
208 | }
209 | defer os.Chdir(originalDir)
210 |
211 | testCases := []struct {
212 | name string
213 | setupFunc func(t *testing.T, tempDir string)
214 | expectedError bool
215 | expectedConfig bool
216 | expectedTags int
217 | }{
218 | {
219 | name: "no config files present",
220 | setupFunc: func(t *testing.T, tempDir string) {
221 | // Empty directory
222 | },
223 | expectedError: false,
224 | expectedConfig: false,
225 | },
226 | {
227 | name: ".tag-nag.yml present",
228 | setupFunc: func(t *testing.T, tempDir string) {
229 | content := "tags:\n - key: Owner\n"
230 | err := os.WriteFile(".tag-nag.yml", []byte(content), 0644)
231 | if err != nil {
232 | t.Fatalf("Failed to create test config: %v", err)
233 | }
234 | },
235 | expectedError: false,
236 | expectedConfig: true,
237 | expectedTags: 1,
238 | },
239 | {
240 | name: ".tag-nag.yaml present",
241 | setupFunc: func(t *testing.T, tempDir string) {
242 | content := "tags:\n - key: Project\n"
243 | err := os.WriteFile(".tag-nag.yaml", []byte(content), 0644)
244 | if err != nil {
245 | t.Fatalf("Failed to create test config: %v", err)
246 | }
247 | },
248 | expectedError: false,
249 | expectedConfig: true,
250 | expectedTags: 1,
251 | },
252 | {
253 | name: "both files present (should prefer .yml)",
254 | setupFunc: func(t *testing.T, tempDir string) {
255 | ymlContent := "tags:\n - key: Owner\n - key: Project\n"
256 | yamlContent := "tags:\n - key: Environment\n"
257 |
258 | err := os.WriteFile(".tag-nag.yml", []byte(ymlContent), 0644)
259 | if err != nil {
260 | t.Fatalf("Failed to create .yml config: %v", err)
261 | }
262 |
263 | err = os.WriteFile(".tag-nag.yaml", []byte(yamlContent), 0644)
264 | if err != nil {
265 | t.Fatalf("Failed to create .yaml config: %v", err)
266 | }
267 | },
268 | expectedError: false,
269 | expectedConfig: true,
270 | expectedTags: 2, // Should use .yml file (2 tags), not .yaml file (1 tag)
271 | },
272 | {
273 | name: "invalid config file",
274 | setupFunc: func(t *testing.T, tempDir string) {
275 | content := "tags:\n - key: Owner\n values: [invalid"
276 | err := os.WriteFile(".tag-nag.yml", []byte(content), 0644)
277 | if err != nil {
278 | t.Fatalf("Failed to create invalid config: %v", err)
279 | }
280 | },
281 | expectedError: true,
282 | },
283 | }
284 |
285 | for _, tc := range testCases {
286 | t.Run(tc.name, func(t *testing.T) {
287 | // Create temporary directory for each test
288 | tempDir, err := os.MkdirTemp("", "tag-nag-test")
289 | if err != nil {
290 | t.Fatalf("Failed to create temp dir: %v", err)
291 | }
292 | defer os.RemoveAll(tempDir)
293 |
294 | // Change to temp directory
295 | err = os.Chdir(tempDir)
296 | if err != nil {
297 | t.Fatalf("Failed to change to temp dir: %v", err)
298 | }
299 |
300 | // Setup test scenario
301 | tc.setupFunc(t, tempDir)
302 |
303 | // Test the function
304 | config, err := FindAndLoadConfigFile()
305 |
306 | // Check error expectation
307 | if tc.expectedError {
308 | if err == nil {
309 | t.Errorf("Expected error but got none")
310 | }
311 | return
312 | }
313 |
314 | if err != nil {
315 | t.Errorf("Unexpected error: %v", err)
316 | return
317 | }
318 |
319 | // Check config presence
320 | if tc.expectedConfig {
321 | if config == nil {
322 | t.Errorf("Expected config but got nil")
323 | return
324 | }
325 | if len(config.Tags) != tc.expectedTags {
326 | t.Errorf("Expected %d tags, got %d", tc.expectedTags, len(config.Tags))
327 | }
328 | } else {
329 | if config != nil {
330 | t.Errorf("Expected no config but got: %+v", config)
331 | }
332 | }
333 | })
334 | }
335 | }
336 |
337 | func TestConvertToTagMap(t *testing.T) {
338 | testCases := []struct {
339 | name string
340 | config Config
341 | expected shared.TagMap
342 | }{
343 | {
344 | name: "empty config",
345 | config: Config{
346 | Tags: []TagDefinition{},
347 | },
348 | expected: shared.TagMap{},
349 | },
350 | {
351 | name: "tags without values",
352 | config: Config{
353 | Tags: []TagDefinition{
354 | {Key: "Owner"},
355 | {Key: "Project"},
356 | },
357 | },
358 | expected: shared.TagMap{
359 | "Owner": []string{},
360 | "Project": []string{},
361 | },
362 | },
363 | {
364 | name: "tags with values",
365 | config: Config{
366 | Tags: []TagDefinition{
367 | {Key: "Owner"},
368 | {Key: "Environment", Values: []string{"Dev", "Test", "Prod"}},
369 | {Key: "Project"},
370 | },
371 | },
372 | expected: shared.TagMap{
373 | "Owner": []string{},
374 | "Environment": []string{"Dev", "Test", "Prod"},
375 | "Project": []string{},
376 | },
377 | },
378 | {
379 | name: "mixed values",
380 | config: Config{
381 | Tags: []TagDefinition{
382 | {Key: "Owner", Values: []string{"Alice", "Bob"}},
383 | {Key: "Environment", Values: []string{"Prod"}},
384 | {Key: "Project"},
385 | },
386 | },
387 | expected: shared.TagMap{
388 | "Owner": []string{"Alice", "Bob"},
389 | "Environment": []string{"Prod"},
390 | "Project": []string{},
391 | },
392 | },
393 | }
394 |
395 | for _, tc := range testCases {
396 | t.Run(tc.name, func(t *testing.T) {
397 | result := tc.config.convertToTagMap()
398 |
399 | // Check each key individually to handle nil vs empty slice differences
400 | if len(result) != len(tc.expected) {
401 | t.Errorf("Expected %d keys, got %d", len(tc.expected), len(result))
402 | return
403 | }
404 |
405 | for key, expectedValues := range tc.expected {
406 | actualValues, exists := result[key]
407 | if !exists {
408 | t.Errorf("Expected key %s not found in result", key)
409 | continue
410 | }
411 |
412 | // Handle nil vs empty slice
413 | if actualValues == nil {
414 | actualValues = []string{}
415 | }
416 | if expectedValues == nil {
417 | expectedValues = []string{}
418 | }
419 |
420 | if !reflect.DeepEqual(actualValues, expectedValues) {
421 | t.Errorf("Key %s: expected %v, got %v", key, expectedValues, actualValues)
422 | }
423 | }
424 | })
425 | }
426 | }
427 |
--------------------------------------------------------------------------------
/main_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "os"
7 | "os/exec"
8 | "strings"
9 | "testing"
10 | )
11 |
12 | const binaryName = "tag-nag"
13 |
14 | type testCases struct {
15 | name string
16 | filePathOrDir string
17 | cliArgs []string
18 | expectedExitCode int
19 | expectedError bool
20 | expectedOutput []string
21 | }
22 |
23 | func TestMain(m *testing.M) {
24 | cmd := exec.Command("go", "build", "-o", binaryName)
25 | if err := cmd.Run(); err != nil {
26 | fmt.Fprintf(os.Stderr, "Failed to build %s: %v\n", binaryName, err)
27 | os.Exit(1)
28 | }
29 |
30 | exitVal := m.Run()
31 | os.Remove(binaryName)
32 | os.Exit(exitVal)
33 | }
34 |
35 | func runTagNag(t *testing.T, args ...string) (string, error, int) {
36 | t.Helper()
37 | fullArgs := append([]string{"./" + binaryName}, args...)
38 | cmd := exec.Command(fullArgs[0], fullArgs[1:]...)
39 | var outbuf, errbuf bytes.Buffer
40 | cmd.Stdout = &outbuf
41 | cmd.Stderr = &errbuf
42 |
43 | err := cmd.Run()
44 | stdout := outbuf.String()
45 | stderr := errbuf.String()
46 |
47 | fullOutput := stdout
48 | if stderr != "" {
49 | fullOutput += "\n" + stderr
50 | }
51 |
52 | exitCode := 0
53 | if err != nil {
54 | if exitError, ok := err.(*exec.ExitError); ok {
55 | exitCode = exitError.ExitCode()
56 | } else {
57 | t.Fatalf("Command execution failed with non-exit error: %v, output: %s", err, fullOutput)
58 | }
59 | }
60 | return fullOutput, err, exitCode
61 | }
62 |
63 | func TestInputs(t *testing.T) {
64 | testCases := []testCases{
65 | {
66 | name: "no dir",
67 | filePathOrDir: "",
68 | cliArgs: []string{"--tags", "Owner"},
69 | expectedExitCode: 1,
70 | expectedError: true,
71 | expectedOutput: []string{"Error: specify a directory or file to scan"},
72 | },
73 | {
74 | name: "no tags",
75 | filePathOrDir: "testdata/terraform/tags.tf",
76 | cliArgs: []string{"nonexistent.yml"},
77 | expectedExitCode: 1,
78 | expectedError: true,
79 | expectedOutput: []string{"specify required tags using --tags or create a .tag-nag.yml config file"},
80 | },
81 |
82 | {
83 | name: "dry run",
84 | filePathOrDir: "testdata/terraform/tags.tf",
85 | cliArgs: []string{"--tags", "Owner,Environment,Project", "--dry-run"},
86 | expectedExitCode: 0,
87 | expectedError: false,
88 | expectedOutput: []string{"Dry-run:", `aws_s3_bucket "this"`, "Missing tags: Project"},
89 | },
90 | }
91 | for _, tc := range testCases {
92 | t.Run(tc.name, func(t *testing.T) {
93 | var argsForRun []string
94 | if tc.filePathOrDir != "" {
95 | argsForRun = append(argsForRun, tc.filePathOrDir)
96 | }
97 | argsForRun = append(argsForRun, tc.cliArgs...)
98 |
99 | output, err, exitCode := runTagNag(t, argsForRun...)
100 |
101 | if tc.expectedError && err == nil {
102 | t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output)
103 | }
104 | if !tc.expectedError && err != nil {
105 | t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output)
106 | }
107 |
108 | if exitCode != tc.expectedExitCode {
109 | t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output)
110 | }
111 |
112 | for _, expectedStr := range tc.expectedOutput {
113 | if !strings.Contains(output, expectedStr) {
114 | t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output)
115 | }
116 | }
117 | })
118 | }
119 | }
120 |
121 | func TestTerraform(t *testing.T) {
122 | testCases := []testCases{
123 | {
124 | name: "tags",
125 | filePathOrDir: "testdata/terraform/tags.tf",
126 | cliArgs: []string{"--tags", "Owner,Environment"},
127 | expectedExitCode: 0,
128 | expectedError: false,
129 | expectedOutput: []string{"No tag violations found"},
130 | },
131 | {
132 | name: "missing tags",
133 | filePathOrDir: "testdata/terraform/tags.tf",
134 | cliArgs: []string{"--tags", "Owner,Environment,Project"},
135 | expectedExitCode: 1,
136 | expectedError: true,
137 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Project"},
138 | },
139 | {
140 | name: "no tags",
141 | filePathOrDir: "testdata/terraform/no_tags.tf",
142 | cliArgs: []string{"--tags", "Owner, Environment"},
143 | expectedExitCode: 1,
144 | expectedError: true,
145 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment, Owner"},
146 | },
147 | {
148 | name: "case insensitive",
149 | filePathOrDir: "testdata/terraform/tags.tf",
150 | cliArgs: []string{"--tags", "owner,environment", "-c"},
151 | expectedExitCode: 0,
152 | expectedError: false,
153 | expectedOutput: []string{"No tag violations found"},
154 | },
155 | {
156 | name: "lower case",
157 | filePathOrDir: "testdata/terraform/tags.tf",
158 | cliArgs: []string{"--tags", "owner"},
159 | expectedExitCode: 1,
160 | expectedError: true,
161 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: owner"},
162 | },
163 | {
164 | name: "tag values",
165 | filePathOrDir: "testdata/terraform/tags.tf",
166 | cliArgs: []string{"--tags", "Owner,Environment[dev,prod]"},
167 | expectedExitCode: 0,
168 | expectedError: false,
169 | expectedOutput: []string{"No tag violations found"},
170 | },
171 | {
172 | name: "missing tag value",
173 | filePathOrDir: "testdata/terraform/tags.tf",
174 | cliArgs: []string{"--tags", "Owner,Environment[test]"},
175 | expectedExitCode: 1,
176 | expectedError: true,
177 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment[test]"},
178 | },
179 | {
180 | name: "tag values case insensitive",
181 | filePathOrDir: "testdata/terraform/tags.tf",
182 | cliArgs: []string{"--tags", "Owner,Environment[Dev,Prod]", "-c"},
183 | expectedExitCode: 0,
184 | expectedError: false,
185 | expectedOutput: []string{"No tag violations found"},
186 | },
187 | {
188 | name: "provider",
189 | filePathOrDir: "testdata/terraform/provider.tf",
190 | cliArgs: []string{"--tags", "Owner,Environment,Project,Source"},
191 | expectedExitCode: 0,
192 | expectedError: false,
193 | expectedOutput: []string{"Found Terraform default tags for provider aws", "No tag violations found"},
194 | },
195 | {
196 | name: "provider",
197 | filePathOrDir: "testdata/terraform/provider.tf",
198 | cliArgs: []string{"--tags", "Owner,Environment,Project,Source"},
199 | expectedExitCode: 0,
200 | expectedError: false,
201 | expectedOutput: []string{"Found Terraform default tags for provider aws: [Project, Source]", "No tag violations found"},
202 | },
203 | {
204 | name: "provider case insensitive",
205 | filePathOrDir: "testdata/terraform/provider.tf",
206 | cliArgs: []string{"--tags", "owner,environment,project,source", "-c"},
207 | expectedExitCode: 0,
208 | expectedError: false,
209 | expectedOutput: []string{"Found Terraform default tags for provider aws: [project, source]", "No tag violations found"},
210 | },
211 | {
212 | name: "provider tag values",
213 | filePathOrDir: "testdata/terraform/provider.tf",
214 | cliArgs: []string{"--tags", "Owner,Environment[dev,prod],Project,Source[my-repo]"},
215 | expectedExitCode: 0,
216 | expectedError: false,
217 | expectedOutput: []string{"Found Terraform default tags for provider aws: [Project, Source]", "No tag violations found"},
218 | },
219 | {
220 | name: "variable tags",
221 | filePathOrDir: "testdata/terraform/referenced_tags.tf",
222 | cliArgs: []string{"--tags", "Owner,Environment"},
223 | expectedExitCode: 0,
224 | expectedError: false,
225 | expectedOutput: []string{"No tag violations found"},
226 | },
227 | {
228 | name: "variable value",
229 | filePathOrDir: "testdata/terraform/referenced_values.tf",
230 | cliArgs: []string{"--tags", "Owner[jakebark],Environment"},
231 | expectedExitCode: 0,
232 | expectedError: false,
233 | expectedOutput: []string{"No tag violations found"},
234 | },
235 | {
236 | name: "local value",
237 | filePathOrDir: "testdata/terraform/referenced_values.tf",
238 | cliArgs: []string{"--tags", "Owner,Environment[dev,prod]"},
239 | expectedExitCode: 0,
240 | expectedError: false,
241 | expectedOutput: []string{"No tag violations found"},
242 | },
243 | {
244 | name: "variable value case insensitive",
245 | filePathOrDir: "testdata/terraform/referenced_values.tf",
246 | cliArgs: []string{"--tags", "Owner[Jakebark],Environment", "-c"},
247 | expectedExitCode: 0,
248 | expectedError: false,
249 | expectedOutput: []string{"No tag violations found"},
250 | },
251 | {
252 | name: "local value case insensitive",
253 | filePathOrDir: "testdata/terraform/referenced_values.tf",
254 | cliArgs: []string{"--tags", "Owner,Environment[DEV,PROD]", "-c"},
255 | expectedExitCode: 0,
256 | expectedError: false,
257 | expectedOutput: []string{"No tag violations found"},
258 | },
259 | {
260 | name: "interpolation",
261 | filePathOrDir: "testdata/terraform/referenced_values.tf",
262 | cliArgs: []string{"--tags", "Owner,Environment,Project[112233],Source[my-repo]"},
263 | expectedExitCode: 0,
264 | expectedError: false,
265 | expectedOutput: []string{"No tag violations found"},
266 | },
267 | {
268 | name: "interpolation missing value",
269 | filePathOrDir: "testdata/terraform/referenced_values.tf",
270 | cliArgs: []string{"--tags", "Owner,Environment,Project[112233],Source[not-my-repo]"},
271 | expectedExitCode: 1,
272 | expectedError: true,
273 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Source[not-my-repo]"},
274 | },
275 | {
276 | name: "example repo",
277 | filePathOrDir: "testdata/terraform/example_repo",
278 | cliArgs: []string{"--tags", "Owner,Environment"},
279 | expectedExitCode: 1,
280 | expectedError: true,
281 | expectedOutput: []string{"Found Terraform default tags for provider aws: [Environment, Owner, Source]", `aws_s3_bucket "baz"`, "Found 1 tag violation(s)"},
282 | },
283 | {
284 | name: "ignore",
285 | filePathOrDir: "testdata/terraform/ignore.tf",
286 | cliArgs: []string{"--tags", "Owner,Environment,Project"},
287 | expectedExitCode: 0,
288 | expectedError: false,
289 | expectedOutput: []string{`aws_s3_bucket "this" skipped`},
290 | },
291 | {
292 | name: "ignore all",
293 | filePathOrDir: "testdata/terraform/ignore_all.tf",
294 | cliArgs: []string{"--tags", "Owner,Environment,Project"},
295 | expectedExitCode: 0,
296 | expectedError: false,
297 | expectedOutput: []string{`aws_s3_bucket "this" skipped`},
298 | },
299 | {
300 | name: "lower function",
301 | filePathOrDir: "testdata/terraform/function.tf",
302 | cliArgs: []string{"--tags", "Environment[dev]"},
303 | expectedExitCode: 0,
304 | expectedError: false,
305 | expectedOutput: []string{"No tag violations found"},
306 | },
307 | {
308 | name: "skip file",
309 | filePathOrDir: "testdata/terraform",
310 | cliArgs: []string{"--tags", "Owner,Environment,Project", "-s", "testdata/terraform/tags.tf"},
311 | expectedExitCode: 1,
312 | expectedError: true,
313 | expectedOutput: []string{`aws_s3_bucket "this"`, "Missing tags: Environment, Owner"},
314 | },
315 | {
316 | name: "skip directory",
317 | filePathOrDir: "testdata",
318 | cliArgs: []string{"--tags", "Owner,Environment,Project", "-s", "testdata/terraform"},
319 | expectedExitCode: 1,
320 | expectedError: true,
321 | expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"},
322 | },
323 | }
324 |
325 | for _, tc := range testCases {
326 | t.Run(tc.name, func(t *testing.T) {
327 | argsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...)
328 | output, err, exitCode := runTagNag(t, argsForRun...)
329 |
330 | if tc.expectedError && err == nil {
331 | t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output)
332 | }
333 | if !tc.expectedError && err != nil {
334 | t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output)
335 | }
336 |
337 | if exitCode != tc.expectedExitCode {
338 | t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output)
339 | }
340 |
341 | for _, expectedStr := range tc.expectedOutput {
342 | if !strings.Contains(output, expectedStr) {
343 | t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output)
344 | }
345 | }
346 | })
347 | }
348 | }
349 |
350 | func TestCloudFormation(t *testing.T) {
351 | testCases := []testCases{
352 | {
353 | name: "yml",
354 | filePathOrDir: "testdata/cloudformation/tags.yml",
355 | cliArgs: []string{"--tags", "Owner,Environment"},
356 | expectedExitCode: 0,
357 | expectedError: false,
358 | expectedOutput: []string{"No tag violations found"},
359 | },
360 | {
361 | name: "yaml",
362 | filePathOrDir: "testdata/cloudformation/tags.yaml",
363 | cliArgs: []string{"--tags", "Owner,Environment"},
364 | expectedExitCode: 0,
365 | expectedError: false,
366 | expectedOutput: []string{"No tag violations found"},
367 | },
368 | {
369 | name: "json",
370 | filePathOrDir: "testdata/cloudformation/tags.json",
371 | cliArgs: []string{"--tags", "Owner,Environment"},
372 | expectedExitCode: 0,
373 | expectedError: false,
374 | expectedOutput: []string{"No tag violations found"},
375 | },
376 | {
377 | name: "yaml missing tags",
378 | filePathOrDir: "testdata/cloudformation/tags.yml",
379 | cliArgs: []string{"--tags", "Owner,Environment,Project"},
380 | expectedExitCode: 1,
381 | expectedError: true,
382 | expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"},
383 | },
384 | {
385 | name: "json missing tags",
386 | filePathOrDir: "testdata/cloudformation/tags.json",
387 | cliArgs: []string{"--tags", "Owner,Environment,Project"},
388 | expectedExitCode: 1,
389 | expectedError: true,
390 | expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: Project"},
391 | },
392 | {
393 | name: "case insensitive",
394 | filePathOrDir: "testdata/cloudformation/tags.yml",
395 | cliArgs: []string{"--tags", "owner,environment", "-c"},
396 | expectedExitCode: 0,
397 | expectedError: false,
398 | expectedOutput: []string{"No tag violations found"},
399 | },
400 | {
401 | name: "lower case",
402 | filePathOrDir: "testdata/cloudformation/tags.yml",
403 | cliArgs: []string{"--tags", "owner"},
404 | expectedExitCode: 1,
405 | expectedError: true,
406 | expectedOutput: []string{`AWS::S3::Bucket "this"`, "Missing tags: owner"},
407 | },
408 | }
409 |
410 | for _, tc := range testCases {
411 | t.Run(tc.name, func(t *testing.T) {
412 | argsForRun := append([]string{tc.filePathOrDir}, tc.cliArgs...)
413 | output, err, exitCode := runTagNag(t, argsForRun...)
414 |
415 | if tc.expectedError && err == nil {
416 | t.Errorf("Expected an error from command execution, but got none. Output:\n%s", output)
417 | }
418 | if !tc.expectedError && err != nil {
419 | t.Errorf("Expected no error from command execution, but got: %v. Output:\n%s", err, output)
420 | }
421 |
422 | if exitCode != tc.expectedExitCode {
423 | t.Errorf("Expected exit code %d, got %d. Output:\n%s", tc.expectedExitCode, exitCode, output)
424 | }
425 |
426 | for _, expectedStr := range tc.expectedOutput {
427 | if !strings.Contains(output, expectedStr) {
428 | t.Errorf("Output missing expected string '%s'. Output:\n%s", expectedStr, output)
429 | }
430 | }
431 | })
432 | }
433 | }
434 |
--------------------------------------------------------------------------------