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