Repository: tfsec/tfsec
Pull request #452 (2020-10-19 18:59:22)

Adding custom checks functionality and guide


Patch:
diff --git a/.travis.yml b/.travis.yml
index 0588f39c0..ab59ff2d1 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -33,6 +33,19 @@ jobs:
       on:
         repo: tfsec/tfsec
         tags: true
+    deploy-checkgen:
+      provider: releases
+      skip_cleanup: true
+      name: tfsec-checkgen $TRAVIS_TAG
+      file:
+        - bin/darwin/tfsec-checkgen-darwin-amd64
+        - bin/linux/tfsec-checkgen-linux-amd64
+        - bin/windows/tfsec-checkgen-windows-amd64.exe
+      api_key:
+        secure: "gnFbED/JgihbV3zO6BgMj1YY7W+LsMHgVbamdMXcwwI4+9ouUa87D3vXIBpbAPUK7/aNmBeqDhXYX9grpy1gUAkmfPXhqH3zuOFnSiwoGFkZe0coRzZiqNU/pzxB4sZDihDLvk/E8btdNmNwSz6JkYvSbmGfN+b6M1N6Ico/kocV/XPYjfhGcArpngJ0CdbP656ixVo+s9ogwQUI3fNeelX0ppBjj1X8HZMMyLPuDvAsslWn0lb4rTqvpJitqcPaff8VRtnrQExNm5dVhEo03t+zYzPt+6EFhhx2K+dPpnWcDfl6yw7E+mKeN/LZIvGx+9pTQ3I3sclvtQ/COQ2WQn3z5wa5h9D/fsKBS/WxYMx8jWU/TcBeNpLDZhJcILwtYISKi9KfxY8Mr4FcHa+3Zl+uGZHWdbVgx8VR7SdDPi+H7QKcNrMJr7HAE/bHYNocE9qygWWpzGupznYX9RCNRhT51ebSlC497ZPugAkEdlax3yZcNf9CRXrCMLguJhoU3lOK8FyKt8Vvr/OTgbdJT7f440N5U3ETn4d+McZTIH0Gu71DfUAivixCVM2Ex6lvzcms1ZTwSsZMs888eI7mnStJoBS2vNo5AjR2jF0/FlCAjQRl9xUY+mCcRDrsQE2mFpqst4DvBhyYqzXrAiAzemcgtCVsmWpYkd9QrpBde04="
+      on:
+        repo: tfsec/tfsec
+        tags: true
   - stage: docs
     if: branch = master
     name: Publish Docs
diff --git a/cmd/tfsec-checkgen/main.go b/cmd/tfsec-checkgen/main.go
new file mode 100644
index 000000000..e10928764
--- /dev/null
+++ b/cmd/tfsec-checkgen/main.go
@@ -0,0 +1,43 @@
+package main
+
+import (
+	"fmt"
+	"github.com/spf13/cobra"
+	"github.com/tfsec/tfsec/internal/app/tfsec/custom"
+	"os"
+)
+
+func init() {
+	rootCmd.AddCommand(validateCmd)
+}
+
+func main() {
+	if err := rootCmd.Execute(); err != nil {
+		fmt.Fprint(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+
+var rootCmd = &cobra.Command{
+	Use:   "tfsec-checkgen",
+	Short: "tfsec-checkgen is a tfsec tool for generating and validating custom check files.",
+	Long: `tfsec is a simple tool for generating and validating custom checks file.
+Custom checks are defined as json and stored in the .tfsec directory of the folder being checked.
+`,
+}
+
+var validateCmd = &cobra.Command{
+	Use:   "validate [checkfile]",
+	Short: "Validate a custom checks file to ensure values are correct",
+	Long:  "Confirm that all of the attributes of the supplied custom checks file are valid and can be used",
+	Args:  cobra.ExactArgs(1),
+	Run: func(cmd *cobra.Command, args []string) {
+		err := custom.Validate(args[0])
+		if err != nil {
+			fmt.Fprint(os.Stderr, err)
+			os.Exit(-1)
+		}
+		fmt.Println("Config is valid")
+		os.Exit(0)
+	},
+}
diff --git a/cmd/tfsec-docs/webpage.go b/cmd/tfsec-docs/webpage.go
index 6d110266b..dd753b301 100644
--- a/cmd/tfsec-docs/webpage.go
+++ b/cmd/tfsec-docs/webpage.go
@@ -14,6 +14,7 @@ const (
 	docsDataFile = `
 - title: Getting Started
   docs:
+  - custom_checks.md
   - home
 
 {{range $p := .}}
diff --git a/cmd/tfsec/main.go b/cmd/tfsec/main.go
index b59282374..f6ed047a2 100644
--- a/cmd/tfsec/main.go
+++ b/cmd/tfsec/main.go
@@ -2,6 +2,7 @@ package main
 
 import (
 	"fmt"
+	"github.com/tfsec/tfsec/internal/app/tfsec/custom"
 	"os"
 	"path/filepath"
 	"runtime"
@@ -14,6 +15,7 @@ import (
 	"github.com/liamg/tml"
 
 	"github.com/spf13/cobra"
+
 	_ "github.com/tfsec/tfsec/internal/app/tfsec/checks"
 	"github.com/tfsec/tfsec/internal/app/tfsec/parser"
 	"github.com/tfsec/tfsec/internal/app/tfsec/scanner"
@@ -83,6 +85,14 @@ var rootCmd = &cobra.Command{
 			os.Exit(1)
 		}
 
+		debug.Log("Loading custom checks...")
+		err = custom.Load(dir)
+		if err != nil {
+			fmt.Fprint(os.Stderr, fmt.Sprintf("There were errors while processing custom check files. %s", err))
+			os.Exit(1)
+		}
+		debug.Log("Custom checks loaded")
+
 		if len(excludedChecks) > 0 {
 			excludedChecksList = strings.Split(excludedChecks, ",")
 		}
diff --git a/docs-website/_docs/custom_checks.md b/docs-website/_docs/custom_checks.md
new file mode 100644
index 000000000..b4a1e0898
--- /dev/null
+++ b/docs-website/_docs/custom_checks.md
@@ -0,0 +1,166 @@
+---
+title: Custom Checks
+permalink: /docs/home/
+---
+
+## What is it?
+tfsec comes with an ever growing number of built in checks, these cover standard AWS, Azure and GCP provider checks, plus several more.
+
+We recognise that there are checks that need performing for an organisation that don't fit with general use cases; for this, there are custom checks.
+
+Custom checks offer an accessible approach to injecting checks that satisfy your organisations compliance and security needs. For example, if you require that all EC2 instances have a `CostCentre` tag, that can be achieved with a `custom_check`.
+
+## How does it work?
+Custom checks are defined as json files which sit in the `.tfsec` folder in the root check path. any file with the suffix `_tfchecks.json` will be parsed and the checks included during the run.
+
+### What does a check file look like?
+Check files are simply json, this ensures that checks can be put together without requiring Go knowledge or being able to build a new release of tfsec to include your custom code.
+
+Taking the previous example of a required cost centre, the check file might look something like
+
+```json
+{
+  "checks": [
+    {
+      "code": "CUS001",
+      "description": "Custom check to ensure the CostCentre tag is applied to EC2 instances",
+      "requiredTypes": [
+        "resource"
+      ],
+      "requiredLabels": [
+        "aws_instance"
+      ],
+      "severity": "ERROR",
+      "matchSpec": {
+        "name": "tags",
+        "action": "contains",
+        "value": "CostCentre"
+      },
+      "errorMessage": "The required CostCentre tag was missing",
+      "relatedLinks": [
+        "http://internal.acmecorp.com/standards/aws/tagging.html"
+      ]
+    }
+  ]   
+}
+```
+
+The check contains up of the following attributes;
+
+| Attribute | Description |
+|:-----------|:-------------|
+| code | The custom code that your check will be known as |
+| description | A description for the code that will be included in the output|
+|requiredTypes | The block types to apply the check to - resource, data, module, variable |
+|requiredLabels | The resource type - aws_ec2_instance for example |
+|severity | How severe is the check |
+|matchSpec | See below for the MatchSpec attributes |
+|errorMessage | The error message that should be displayed in cases where the check fails |
+|relatedLinks | A list of related links for the check to be displayed in cases where the check fails |
+
+
+The `MatchSpec` is the what will define the check itself - this is fairly basic and is made up of the following attributes
+
+| Attribute | Description |
+|:----------|:------------|
+| name | The name of the attribute or block to run the check on |
+| action | The check type - see below for more information |
+| value | In cases where a value is required, the value to look for |
+|subMatch | A sub MatchSpec block for nested checking - think looking for `enabled` value in a `logging` block |
+
+#### Check Actions
+There are a number of `CheckActions` available which should allow you to quickly put together most checks.
+
+##### inModule
+The `inModule` check action passes if the resource block is a component of a module. For example, if you're looking to check that an `aws_s3_bucket` is only created using a custom module, you could use the following `MatchSpec`;
+
+```json
+"matchSpec" : {
+  "action": "inModule"
+}
+```
+
+##### isPresent
+The `isPresent` check action passes if the required block or attribute is available in the checked block. For example, if you're looking to check that an `acl` is provided and don't care what it is, you can use the following `MatchSpec`;
+
+```json
+"matchSpec" : {
+  "name": "acl",
+  "action": "isPresent"
+}
+```
+
+##### notPresent
+Conversely, the `noPresent` check action passes if the specified block or attribute is not found in the checked block. For example, if you explicitly don't want an `acl` attribute to be present hou can use the following `MatchSpec`
+
+```json
+"matchSpec" : {
+  "name": "acl",
+  "action": "notPresent"
+}
+```
+
+##### startsWith
+The `startsWith` check action passes if the checked attribute string starts with the specified value. For example, to check that `acl` begins with `public` you could use the following `MatchSpec`
+
+```json
+"matchSpec" : {
+  "name": "acl",
+  "action": "startsWith",
+  "value": "public"
+}
+```
+
+##### endsWith
+The `endsWith` check action passes if the checked attribute string ends with the specified value. For example, to check that `acl` begins with `public` you could use the following `MatchSpec`;
+
+```json
+"matchSpec" : {
+  "name": "acl",
+  "action": "endsWith",
+  "value": "-read"
+}
+```
+
+##### contains
+The `contains` check action will change depending on the attribute or block it is applied to. If the check is against a string attribute, it will look for the `MatchSpec` value in the attribute. If the check is against a list, it will pass if the value item can be found in the list.
+
+If the attribute is an `object` or `map` it will pass if a key can be found that matches the `MatchSpec` value.
+
+For example, if you want to ensure that the `CostCentre` exists, you might use the following `MatchSpec`;
+
+```json
+"matchSpec" : {
+  "name": "tags",
+  "action": "contains",
+  "value": "CostCentre"
+}
+```
+
+##### equals 
+The `equals` check action passes if the checked attribute equals specified value. 
+The core primitive types are supported, if the subject attribute is a Boolean, the `MatchSpec` value will attempt to be cast to a Boolean for comparison.
+For example, to check that `acl` begins with `private` you could use the following `MatchSpec`;
+
+```json
+"matchSpec" : {
+  "name": "acl",
+  "action": "equals",
+  "value": "private"
+}
+
+```
+## How do I know my JSON is valid?
+We have provided the `tfsec-checkgen` binary which will validate your check file to ensure that it is valid for use with `tfsec`. 
+
+In the future, `tfsec-checkgen` will facilitate the creation of new check files with a wizard approach, for now it can be used to validate your check file.
+
+```shell script
+./tfsec-checkgen validate example/custom/.tfsec/custom_checks.json
+```
+
+
+## Are there limitations?
+At the moment, check `MatchSpec` is limited in the number of check types it can perform, these are as shown in the previous table.
+
+Custom defined checks also don't come with the comprehensive tests that the built in ones have. This will be addressed in future releases.
diff --git a/example/custom/.tfsec/custom_tfchecks.json b/example/custom/.tfsec/custom_tfchecks.json
new file mode 100644
index 000000000..32acae432
--- /dev/null
+++ b/example/custom/.tfsec/custom_tfchecks.json
@@ -0,0 +1,66 @@
+{
+  "checks": [
+    {
+      "code": "CUS001",
+      "description": "Custom check to ensure the CostCentre tag is applied to EC2 instances",
+      "requiredTypes": [
+        "resource"
+      ],
+      "requiredLabels": [
+        "aws_instance"
+      ],
+      "severity": "ERROR",
+      "matchSpec": {
+        "name": "tags",
+        "action": "contains",
+        "value": "CostCentre"
+      },
+      "errorMessage": "The required CostCentre tag was missing",
+      "relatedLinks": [
+        "http://internal.acmecorp.com/standards/aws/tagging.html"
+      ]
+    },
+    {
+      "code": "CUS002",
+      "description": "Custom check to ensure S3 buckets have versioning enabled",
+      "requiredTypes": [
+        "resource"
+      ],
+      "requiredLabels": [
+        "aws_s3_bucket"
+      ],
+      "severity": "WARNING",
+      "matchSpec": {
+        "name": "versioning",
+        "action": "isPresent",
+        "subMatch": {
+          "name" : "enabled",
+          "action": "equals",
+          "value" : "true"
+        }
+      },
+      "errorMessage": "The the versioning block requires enabled attribute",
+      "relatedLinks": [
+        "http://internal.acmecorp.com/standards/aws/tagging.html"
+      ]
+    },
+    {
+      "code": "CUS003",
+      "description": "Custom check to ensure S3 buckets are only created using the custom_bucket module",
+      "requiredTypes": [
+        "resource"
+      ],
+      "requiredLabels": [
+        "aws_s3_bucket"
+      ],
+      "severity": "ERROR",
+      "matchSpec": {
+        "action": "inModule"
+      },
+      "errorMessage": "S3 buckets must only be created using the custom bucket module",
+      "relatedLinks": [
+        "http://internal.acmecorp.com/standards/aws/s3_buckets.html"
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/example/custom/custom_check.tf b/example/custom/custom_check.tf
new file mode 100644
index 000000000..da99cfc9b
--- /dev/null
+++ b/example/custom/custom_check.tf
@@ -0,0 +1,46 @@
+resource "aws_instance" "non_compliant" {
+  ami           = "ami-1234"
+  instance_type = "t2.small"
+
+  tags = {
+    Department = "Finance"
+  }
+
+}
+
+resource "aws_instance" "compliant" {
+  ami           = "ami-1234"
+  instance_type = "t2.small"
+
+  tags = {
+    Department = "Finance"
+    CostCentre = "CC1234"
+  }
+}
+
+resource "aws_s3_bucket" "unversioned_bucket" {
+  bucket = "my-tf-test-bucket"
+  acl    = "private"
+}
+
+resource "aws_s3_bucket" "versioned_bucket" {
+  bucket = "my-tf-test-bucket"
+  acl    = "private"
+
+  versioning {
+    enabled = true
+  }
+}
+
+resource "aws_s3_bucket" "disabled_versioned_bucket" {
+  bucket = "my-tf-test-bucket"
+  acl    = "private"
+
+  versioning {
+    enabled = true
+  }
+}
+
+module "custom_bucket" {
+  source = "./modules/custom_bucket"
+}
\ No newline at end of file
diff --git a/example/custom/modules/custom_bucket/main.tf b/example/custom/modules/custom_bucket/main.tf
new file mode 100644
index 000000000..45c859038
--- /dev/null
+++ b/example/custom/modules/custom_bucket/main.tf
@@ -0,0 +1,8 @@
+resource "aws_s3_bucket" "custom_module_bucket" {
+  bucket = "my-custom-module-bucket"
+  acl    = "private"
+
+  versioning {
+    enabled = true
+  }
+}
\ No newline at end of file
diff --git a/go.mod b/go.mod
index e3d0b5f66..3a4d63686 100644
--- a/go.mod
+++ b/go.mod
@@ -1,14 +1,16 @@
 module github.com/tfsec/tfsec
 
-go 1.14
+go 1.15
 
 require (
 	github.com/hashicorp/hcl/v2 v2.3.0
 	github.com/hashicorp/terraform v0.12.28
 	github.com/liamg/clinch v1.3.0
 	github.com/liamg/tml v0.2.0
+	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
 	github.com/spf13/cobra v1.0.0
 	github.com/stretchr/testify v1.5.1
 	github.com/zclconf/go-cty v1.5.1
 	github.com/zclconf/go-cty-yaml v1.0.1
+	gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
 )
diff --git a/go.sum b/go.sum
index 2b192797a..09e98a81f 100644
--- a/go.sum
+++ b/go.sum
@@ -339,6 +339,8 @@ github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3Rllmb
 github.com/mozillazg/go-httpheader v0.2.1 h1:geV7TrjbL8KXSyvghnFm+NyTux/hxwueTSrwhe88TQQ=
 github.com/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
 github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
+github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
 github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
 github.com/oklog/run v1.0.0 h1:Ru7dDtJNOyC66gQ5dQmaCa0qIsAUFY3sFpK1Xk8igrw=
@@ -560,6 +562,8 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
+gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/cheggaaa/pb.v1 v1.0.27/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
 gopkg.in/ini.v1 v1.42.0 h1:7N3gPTt50s8GuLortA00n8AqRTk75qOP98+mTPpgzRk=
 gopkg.in/ini.v1 v1.42.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
diff --git a/internal/app/tfsec/custom/custom_check.go b/internal/app/tfsec/custom/custom_check.go
new file mode 100644
index 000000000..72da5fa1d
--- /dev/null
+++ b/internal/app/tfsec/custom/custom_check.go
@@ -0,0 +1,62 @@
+package custom
+
+import (
+	"github.com/tfsec/tfsec/internal/app/tfsec/scanner"
+)
+
+type MatchType string
+type CheckAction string
+
+var ValidCheckActions = []CheckAction{
+	InModule, IsPresent, NotPresent, StartsWith, EndsWith, Contains, Equals,
+}
+
+// InModule checks that the block is part of a module
+const InModule CheckAction = "inModule"
+
+// IsPresent checks that the named child is present in the block
+const IsPresent CheckAction = "isPresent"
+
+// NotPresent checks that the named child is absent in the block
+const NotPresent CheckAction = "notPresent"
+
+// StartsWith checks that the named child attribute has a value that starts with the check value
+const StartsWith CheckAction = "startsWith"
+
+// EndsWith checks that the named child attribute has a value that ends with the check value
+const EndsWith CheckAction = "endsWith"
+
+// Contains checks that the named child attribute has a value in the map, list or attribute
+const Contains CheckAction = "contains"
+
+// Contains checks that the named child attribute has a value equal to the check value
+const Equals CheckAction = "equals"
+
+// MatchSpec specifies the checks that should be performed
+type MatchSpec struct {
+	Name       string      `json:"name,omitempty"`
+	MatchValue string      `json:"value,omitempty"`
+	Action     CheckAction `json:"action,omitempty"`
+	SubMatch   *MatchSpec  `json:"subMatch,omitempty"`
+}
+
+// CustomCheck specifies the check definition represented in json
+type Check struct {
+	Code           scanner.RuleCode    `json:"code"`
+	Description    scanner.RuleSummary `json:"description"`
+	RequiredTypes  []string            `json:"requiredTypes"`
+	RequiredLabels []string            `json:"requiredLabels"`
+	Severity       scanner.Severity    `json:"severity"`
+	ErrorMessage   string              `json:"errorMessage,omitempty"`
+	MatchSpec      *MatchSpec          `json:"matchSpec"`
+	RelatedLinks   []string            `json:"relatedLinks,omitempty"`
+}
+
+func (action *CheckAction) isValid() bool {
+	for _, checkAction := range ValidCheckActions {
+		if checkAction == *action {
+			return true
+		}
+	}
+	return false
+}
diff --git a/internal/app/tfsec/custom/loader.go b/internal/app/tfsec/custom/loader.go
new file mode 100644
index 000000000..c191bfd6b
--- /dev/null
+++ b/internal/app/tfsec/custom/loader.go
@@ -0,0 +1,91 @@
+package custom
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io/ioutil"
+	"os"
+	"path"
+	"regexp"
+	"strings"
+)
+
+type ChecksFile struct {
+	Checks []*Check `json:"checks"`
+}
+
+func Load(rootPath string) error {
+	customCheckDir := fmt.Sprintf("%s/.tfsec", rootPath)
+	_, err := os.Stat(customCheckDir)
+	if os.IsNotExist(err) {
+		return nil
+	} else if err != nil {
+		return err
+	}
+
+	return loadCustomChecks(customCheckDir)
+}
+
+func loadCustomChecks(customCheckDir string) error {
+	files, err := listFiles(customCheckDir, ".*_tfchecks.json")
+	if err != nil {
+		return err
+	}
+	var errorList []string
+	for _, file := range files {
+		checkFilePath := path.Join(customCheckDir, file.Name())
+		err = Validate(checkFilePath)
+		if err != nil {
+			errorList = append(errorList, err.Error())
+			continue
+		}
+		checks, err := loadCheckFile(checkFilePath)
+		if err != nil {
+			errorList = append(errorList, err.Error())
+			continue
+		}
+
+		processFoundChecks(checks)
+	}
+
+	if len(errorList) > 0 {
+		return errors.New(strings.Join(errorList, "\n"))
+	}
+	return nil
+}
+
+func loadCheckFile(checkFilePath string) (ChecksFile, error) {
+	var checks ChecksFile
+	checkJson, err := ioutil.ReadFile(checkFilePath)
+	if err != nil {
+		return checks, err
+	}
+	err = json.Unmarshal(checkJson, &checks)
+	if err != nil {
+		return checks, err
+	}
+	return checks, nil
+}
+
+func listFiles(dir, pattern string) ([]os.FileInfo, error) {
+	files, err := ioutil.ReadDir(dir)
+	if err != nil {
+		return nil, err
+	}
+
+	filteredFiles := []os.FileInfo{}
+	for _, file := range files {
+		if file.IsDir() {
+			continue
+		}
+		matched, err := regexp.MatchString(pattern, file.Name())
+		if err != nil {
+			return nil, err
+		}
+		if matched {
+			filteredFiles = append(filteredFiles, file)
+		}
+	}
+	return filteredFiles, nil
+}
diff --git a/internal/app/tfsec/custom/processing.go b/internal/app/tfsec/custom/processing.go
new file mode 100644
index 000000000..fe9642e5d
--- /dev/null
+++ b/internal/app/tfsec/custom/processing.go
@@ -0,0 +1,75 @@
+package custom
+
+import (
+	"fmt"
+	"github.com/tfsec/tfsec/internal/app/tfsec/parser"
+	"github.com/tfsec/tfsec/internal/app/tfsec/scanner"
+)
+
+func processFoundChecks(checks ChecksFile) {
+	for _, customCheck := range checks.Checks {
+		func(customCheck Check) {
+			fmt.Printf("Loading check: %s\n", customCheck.Code)
+			scanner.RegisterCheck(scanner.Check{
+				Code: customCheck.Code,
+				Documentation: scanner.CheckDocumentation{
+					Summary: scanner.RuleSummary(customCheck.Code),
+					Links:   customCheck.RelatedLinks,
+				},
+				Provider:       "custom",
+				RequiredTypes:  customCheck.RequiredTypes,
+				RequiredLabels: customCheck.RequiredLabels,
+				CheckFunc: func(check *scanner.Check, rootBlock *parser.Block, _ *scanner.Context) []scanner.Result {
+					matchSpec := customCheck.MatchSpec
+					if !evalMatchSpec(rootBlock, matchSpec) {
+						return []scanner.Result{
+							check.NewResult(
+								fmt.Sprintf("Custom check failed for resource %s. %s", rootBlock.Name(), customCheck.ErrorMessage),
+								rootBlock.Range(),
+								customCheck.Severity,
+							),
+						}
+					}
+					return nil
+				},
+			})
+		}(*customCheck)
+	}
+}
+
+func evalMatchSpec(block *parser.Block, spec *MatchSpec) bool {
+	if block == nil {
+		return false
+	}
+	evalResult := false
+	attribute := block.GetAttribute(spec.Name)
+	switch spec.Action {
+	case InModule:
+		return block.InModule()
+	case IsPresent:
+		evalResult = block.HasChild(spec.Name)
+		break
+	case NotPresent:
+		evalResult = !block.HasChild(spec.Name)
+		break
+	case StartsWith:
+		evalResult = attribute != nil && attribute.StartsWith(spec.MatchValue)
+		break
+	case EndsWith:
+		evalResult = attribute != nil && attribute.EndsWith(spec.MatchValue)
+		break
+	case Contains:
+		evalResult = attribute != nil && attribute.Contains(spec.MatchValue)
+		break
+	case Equals:
+		evalResult = attribute != nil && attribute.Equals(spec.MatchValue)
+		break
+	}
+
+	if spec.SubMatch != nil {
+		subBlock := block.GetBlock(spec.Name)
+		evalResult = evalMatchSpec(subBlock, spec.SubMatch)
+	}
+
+	return evalResult
+}
diff --git a/internal/app/tfsec/custom/validate.go b/internal/app/tfsec/custom/validate.go
new file mode 100644
index 000000000..7ace10c50
--- /dev/null
+++ b/internal/app/tfsec/custom/validate.go
@@ -0,0 +1,85 @@
+package custom
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"github.com/tfsec/tfsec/internal/app/tfsec/scanner"
+	"os"
+	"strings"
+)
+
+func Validate(checkFilePath string) error {
+	if _, err := os.Stat(checkFilePath); os.IsNotExist(err) {
+		return errors.New(fmt.Sprintf("check file could not be found at path %s", checkFilePath))
+	}
+
+	checkFile, err := loadCheckFile(checkFilePath)
+	if err != nil {
+		return err
+	}
+
+	var errorList []string
+	for _, check := range checkFile.Checks {
+		if err = func(check *Check) error {
+			errs := validate(check)
+			if len(errs) > 0 {
+				jsonContent, err := json.MarshalIndent(check, "", "  ")
+				if err != nil {
+					return errors.New("check json is not valid")
+				}
+				errorStrings := getErrorStrings(errs)
+				return errors.New(fmt.Sprintf("check failed with the following errors;\n\n - %s\n\n%s\n", errorStrings, jsonContent))
+			}
+			return nil
+		}(check); err != nil {
+			errorList = append(errorList, err.Error())
+		}
+	}
+	if len(errorList) > 0 {
+		return errors.New(strings.Join(errorList, "\n"))
+	}
+	return nil
+}
+
+func getErrorStrings(errs []error) string {
+	var errorStrings []string
+	for _, err := range errs {
+		errorStrings = append(errorStrings, err.Error())
+	}
+	return strings.Join(errorStrings, "\n - ")
+}
+
+func validate(check *Check) []error {
+	var checkErrors []error
+	if len(check.Code) == 0 {
+		checkErrors = append(checkErrors, errors.New("check.Code requires a value"))
+	}
+	if len(check.Description) == 0 {
+		checkErrors = append(checkErrors, errors.New("check.Description requires a value"))
+	}
+	if !check.Severity.IsValid() {
+		checkErrors = append(checkErrors, errors.New(fmt.Sprintf("check.Severity[%s] is not a recognised option. Should be %s", check.Severity, scanner.ValidSeverity)))
+	}
+	if len(check.RequiredTypes) == 0 {
+		checkErrors = append(checkErrors, errors.New("check.RequiredTypes requires a value"))
+	}
+	if len(check.RequiredLabels) == 0 {
+		checkErrors = append(checkErrors, errors.New("check.RequiredLabels requires a value"))
+	}
+	return validateMatchSpec(check.MatchSpec, check, checkErrors)
+}
+
+func validateMatchSpec(spec *MatchSpec, check *Check, checkErrors []error) []error {
+	if !spec.Action.isValid() {
+		checkErrors = append(checkErrors, errors.New(fmt.Sprintf("matchSpec.Action[%s] is not a recognised option. Should be %s", spec.Action, ValidCheckActions)))
+	}
+	// if the check is inModule, no name is required
+	if len(spec.Name) == 0 && spec.Action != "inModule" {
+		checkErrors = append(checkErrors, errors.New("matchSpec.Name requires a value"))
+	}
+	if spec.SubMatch != nil {
+		return validateMatchSpec(spec.SubMatch, check, checkErrors)
+	}
+	return checkErrors
+}
diff --git a/internal/app/tfsec/parser/attribute.go b/internal/app/tfsec/parser/attribute.go
index 5b884d6f0..693d396c4 100644
--- a/internal/app/tfsec/parser/attribute.go
+++ b/internal/app/tfsec/parser/attribute.go
@@ -3,7 +3,11 @@ package parser
 import (
 	"github.com/hashicorp/hcl/v2"
 	"github.com/hashicorp/hcl/v2/hclsyntax"
+	"github.com/tfsec/tfsec/internal/app/tfsec/debug"
 	"github.com/zclconf/go-cty/cty"
+	"github.com/zclconf/go-cty/cty/gocty"
+	"strconv"
+	"strings"
 )
 
 type Attribute struct {
@@ -48,3 +52,66 @@ func (attr *Attribute) Range() Range {
 func (attr *Attribute) Name() string {
 	return attr.hclAttribute.Name
 }
+
+func (attr *Attribute) Contains(checkValue string) bool {
+	val := attr.Value()
+	if val.IsNull() {
+		return false
+	}
+	if val.Type().IsObjectType() || val.Type().IsMapType() {
+		valueMap := val.AsValueMap()
+		for key := range valueMap {
+			if key == checkValue {
+				return true
+			}
+		}
+		return false
+	}
+	if val.Type().IsListType() || val.Type().IsTupleType() {
+		valueSlice := val.AsValueSlice()
+		for _, value := range valueSlice {
+			if value.AsString() == checkValue {
+				return true
+			}
+		}
+		return false
+	}
+	return strings.Contains(val.AsString(), checkValue)
+}
+
+func (attr *Attribute) StartsWith(prefix string) bool {
+	if attr.Value().Type() == cty.String {
+		return strings.HasPrefix(attr.Value().AsString(), prefix)
+	}
+	return false
+}
+
+func (attr *Attribute) EndsWith(prefix string) bool {
+	if attr.Value().Type() == cty.String {
+		return strings.HasSuffix(attr.Value().AsString(), prefix)
+	}
+	return false
+}
+
+func (attr *Attribute) Equals(checkValue string) bool {
+	if attr.Value().Type() == cty.String {
+		return strings.EqualFold(attr.Value().AsString(), checkValue)
+	}
+	if attr.Value().Type() == cty.Bool {
+		checkBool, err := strconv.ParseBool(checkValue)
+		if err != nil {
+			debug.Log("Error converting bool for equality check. %s", err)
+			return false
+		}
+		return attr.Value().True() == checkBool
+	}
+	if attr.Value().Type() == cty.Number {
+		checkNumber, err := gocty.ToCtyValue(checkValue, cty.Number)
+		if err != nil {
+			debug.Log("Error converting bool for equality check. %s", err)
+			return false
+		}
+		return attr.Value().RawEquals(checkNumber)
+	}
+	return false
+}
diff --git a/internal/app/tfsec/parser/block.go b/internal/app/tfsec/parser/block.go
index 7995c8420..61ad837ed 100644
--- a/internal/app/tfsec/parser/block.go
+++ b/internal/app/tfsec/parser/block.go
@@ -1,6 +1,7 @@
 package parser
 
 import (
+	"fmt"
 	"strings"
 
 	"github.com/hashicorp/hcl/v2"
@@ -11,6 +12,7 @@ type Block struct {
 	hclBlock *hcl.Block
 	ctx      *hcl.EvalContext
 	prefix   string
+	inModule bool
 }
 
 type Blocks []*Block
@@ -18,7 +20,7 @@ type Blocks []*Block
 func (blocks Blocks) OfType(t string) Blocks {
 	var results []*Block
 	for _, block := range blocks {
-		if block.Type() == t && block.prefix == "" {
+		if block.Type() == t {
 			results = append(results, block)
 		}
 	}
@@ -26,20 +28,16 @@ func (blocks Blocks) OfType(t string) Blocks {
 }
 
 func (blocks Blocks) RemoveDuplicates() Blocks {
-	var filtered Blocks
+	filtered := make(map[string]Block)
 	for _, block := range blocks {
-		var found bool
-		for _, current := range filtered {
-			if current.Range() == block.Range() {
-				found = true
-				break
-			}
-		}
-		if !found {
-			filtered = append(filtered, block)
-		}
+		filtered[block.identifier()] = *block
 	}
-	return filtered
+	var blockSet Blocks
+	for key := range filtered {
+		block := filtered[key]
+		blockSet = append(blockSet, &block)
+	}
+	return blockSet
 }
 
 func NewBlock(hclBlock *hcl.Block, ctx *hcl.EvalContext) *Block {
@@ -163,8 +161,20 @@ func (block *Block) Name() string {
 	if block.Type() != "resource" {
 		prefix = block.Type() + "."
 	}
-	if block.prefix != "" {
-		prefix = block.prefix + "." + prefix
+	if block.Type() == "output" && block.inModule {
+		prefix = fmt.Sprintf("%s.", block.prefix)
 	}
 	return prefix + strings.Join(block.Labels(), ".")
 }
+
+func (block *Block) HasChild(childElement string) bool {
+	return block.GetAttribute(childElement) != nil || block.GetBlock(childElement) != nil
+}
+
+func (block *Block) InModule() bool {
+	return block.inModule
+}
+
+func (block *Block) identifier() string {
+	return fmt.Sprintf("%s:%s:%s", block.Range().Filename, block.Type(), strings.Join(block.Labels(), ":"))
+}
diff --git a/internal/app/tfsec/parser/parser.go b/internal/app/tfsec/parser/parser.go
index 20e35222d..40359939c 100644
--- a/internal/app/tfsec/parser/parser.go
+++ b/internal/app/tfsec/parser/parser.go
@@ -214,7 +214,12 @@ func (parser *Parser) buildEvaluationContext(
 			}
 			moduleName := moduleBlock.Labels[0]
 
-			moduleBlocks[moduleName], moduleMap[moduleName] = parser.parseModuleBlock(moduleBlock, ctx, path, pc, excludedDirectories) // todo return parsed blocks here too
+			result, nameValue := parser.parseModuleBlock(moduleBlock, ctx, path, pc, excludedDirectories) // todo return parsed blocks here too
+			for _, block := range result {
+				block.inModule = true
+			}
+			moduleBlocks[moduleName] = result
+			moduleMap[moduleName] = nameValue
 			ctx.Variables["module"] = cty.ObjectVal(moduleMap)
 		}
 
diff --git a/internal/app/tfsec/scanner/check.go b/internal/app/tfsec/scanner/check.go
index 937db9641..cadf27c68 100644
--- a/internal/app/tfsec/scanner/check.go
+++ b/internal/app/tfsec/scanner/check.go
@@ -11,7 +11,11 @@ import (
 
 // RuleCode is a unique identifier for a check
 type RuleCode string
+
+// RuleSummary is a summary description for a check
 type RuleSummary string
+
+// RuleProvider is the provider that the check applies to
 type RuleProvider string
 
 const (
diff --git a/internal/app/tfsec/scanner/result.go b/internal/app/tfsec/scanner/result.go
index c31fab449..40a3b10cd 100644
--- a/internal/app/tfsec/scanner/result.go
+++ b/internal/app/tfsec/scanner/result.go
@@ -22,3 +22,20 @@ const (
 	SeverityWarning Severity = "WARNING"
 	SeverityInfo    Severity = "INFO"
 )
+
+var ValidSeverity = []Severity{
+	SeverityError, SeverityWarning, SeverityInfo,
+}
+
+func (s *Severity) IsValid() bool {
+	for _, severity := range ValidSeverity {
+		if severity == *s {
+			return true
+		}
+	}
+	return false
+}
+
+func (s *Severity) Valid() []Severity {
+	return ValidSeverity
+}
diff --git a/scripts/build.sh b/scripts/build.sh
index c92e748ef..3abb05688 100755
--- a/scripts/build.sh
+++ b/scripts/build.sh
@@ -1,12 +1,16 @@
 #!/bin/bash
 BINARY=tfsec
+CHECK_GEN_BINARY=tfsec-checkgen
 TAG=${TRAVIS_TAG:-development}
 GO111MODULE=on
 export CGO_ENABLED=0
 export GOFLAGS=-mod=vendor
 mkdir -p bin/darwin
 GOOS=darwin GOARCH=amd64 go build -o bin/darwin/${BINARY}-darwin-amd64 -ldflags "-X github.com/tfsec/tfsec/version.Version=${TAG}" ./cmd/tfsec/
+GOOS=darwin GOARCH=amd64 go build -o bin/darwin/${CHECK_GEN_BINARY}-darwin-amd64 -ldflags "-X github.com/tfsec/tfsec/version.Version=${TAG}" ./cmd/tfsec-checkgen/
 mkdir -p bin/linux
 GOOS=linux GOARCH=amd64 go build -o bin/linux/${BINARY}-linux-amd64 -ldflags "-X github.com/tfsec/tfsec/version.Version=${TAG}" ./cmd/tfsec/
+GOOS=linux GOARCH=amd64 go build -o bin/linux/${CHECK_GEN_BINARY}-linux-amd64 -ldflags "-X github.com/tfsec/tfsec/version.Version=${TAG}" ./cmd/tfsec-checkgen/
 mkdir -p bin/windows
 GOOS=windows GOARCH=amd64 go build -o bin/windows/${BINARY}-windows-amd64.exe -ldflags "-X github.com/tfsec/tfsec/version.Version=${TRAVIS_TAG}" ./cmd/tfsec/
+GOOS=windows GOARCH=amd64 go build -o bin/windows/${CHECK_GEN_BINARY}-windows-amd64.exe -ldflags "-X github.com/tfsec/tfsec/version.Version=${TRAVIS_TAG}" ./cmd/tfsec-checkgen/
diff --git a/vendor/modules.txt b/vendor/modules.txt
index cccece9a4..78e137379 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -37,6 +37,8 @@ github.com/liamg/tml
 github.com/mitchellh/go-homedir
 # github.com/mitchellh/go-wordwrap v1.0.0
 github.com/mitchellh/go-wordwrap
+# github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e
+## explicit
 # github.com/pmezard/go-difflib v1.0.0
 github.com/pmezard/go-difflib/difflib
 # github.com/spf13/cobra v1.0.0
@@ -70,5 +72,7 @@ golang.org/x/sys/windows
 # golang.org/x/text v0.3.2
 golang.org/x/text/transform
 golang.org/x/text/unicode/norm
+# gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f
+## explicit
 # gopkg.in/yaml.v2 v2.2.2
 gopkg.in/yaml.v2


Test patch:
diff --git a/internal/app/tfsec/parser/attribute_test.go b/internal/app/tfsec/parser/attribute_test.go
new file mode 100644
index 000000000..cb2d27512
--- /dev/null
+++ b/internal/app/tfsec/parser/attribute_test.go
@@ -0,0 +1,195 @@
+package parser
+
+import (
+	"github.com/stretchr/testify/assert"
+	"testing"
+)
+
+func Test_AttributeStartsWith(t *testing.T) {
+	var tests = []struct {
+		name           string
+		source         string
+		checkAttribute string
+		checkValue     string
+		expectedResult bool
+	}{
+		{
+			name: "bucket name starts with bucket",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+}`,
+			checkAttribute: "bucket_name",
+			checkValue:     "bucket",
+			expectedResult: true,
+		},
+		{
+			name: "bucket acl starts with public",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+}`,
+			checkAttribute: "acl",
+			checkValue:     "public",
+			expectedResult: true,
+		},
+		{
+			name: "bucket name doesn't start with secret",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+	logging {
+		target_bucket = aws_s3_bucket.log_bucket.id
+		target_prefix = "log/"
+	}
+}`,
+			checkAttribute: "bucket_name",
+			checkValue:     "secret_",
+			expectedResult: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			blocks := createBlocksFromSource(test.source)
+			for _, block := range blocks {
+				if !block.HasChild(test.checkAttribute) {
+					t.Fail()
+				}
+				attr := block.GetAttribute(test.checkAttribute)
+				assert.Equal(t, attr.StartsWith(test.checkValue), test.expectedResult)
+			}
+		})
+	}
+}
+
+func Test_AttributeEndsWith(t *testing.T) {
+	var tests = []struct {
+		name           string
+		source         string
+		checkAttribute string
+		checkValue     string
+		expectedResult bool
+	}{
+		{
+			name: "bucket name ends with Name",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+}`,
+			checkAttribute: "bucket_name",
+			checkValue:     "Name",
+			expectedResult: true,
+		},
+		{
+			name: "bucket acl ends with read not Read",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+}`,
+			checkAttribute: "acl",
+			checkValue:     "Read",
+			expectedResult: false,
+		},
+		{
+			name: "bucket name doesn't end with bucket",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+	logging {
+		target_bucket = aws_s3_bucket.log_bucket.id
+		target_prefix = "log/"
+	}
+}`,
+			checkAttribute: "bucket_name",
+			checkValue:     "_bucket",
+			expectedResult: false,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			blocks := createBlocksFromSource(test.source)
+			for _, block := range blocks {
+				if !block.HasChild(test.checkAttribute) {
+					t.Fail()
+				}
+				attr := block.GetAttribute(test.checkAttribute)
+				assert.Equal(t, attr.EndsWith(test.checkValue), test.expectedResult)
+			}
+		})
+	}
+}
+
+func Test_AttributeContains(t *testing.T) {
+	var tests = []struct {
+		name           string
+		source         string
+		checkAttribute string
+		checkValue     string
+		expectedResult bool
+	}{
+		{
+			name: "bucket name contains Name",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+}`,
+			checkAttribute: "bucket_name",
+			checkValue:     "etNa",
+			expectedResult: true,
+		},
+		{
+			name: "bucket acl doesn't contain private",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+}`,
+			checkAttribute: "acl",
+			checkValue:     "private",
+			expectedResult: false,
+		},
+		{
+			name: "tags attribute is a map with a Department key",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+	tags = {
+		Department = "Finance"
+	}
+}`,
+			checkAttribute: "tags",
+			checkValue:     "Department",
+			expectedResult: true,
+		},
+		{
+			name: "cidr_block has expected subnet",
+			source: `
+resource "aws_security_group" "my-security_group" {
+	cidr_block = ["10.0.0.0/16", "172.0.0.0/8" ] 
+}`,
+			checkAttribute: "cidr_block",
+			checkValue:     "172.0.0.0/8",
+			expectedResult: true,
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			blocks := createBlocksFromSource(test.source)
+			for _, block := range blocks {
+				if !block.HasChild(test.checkAttribute) {
+					t.Fail()
+				}
+				attr := block.GetAttribute(test.checkAttribute)
+				assert.Equal(t, attr.Contains(test.checkValue), test.expectedResult)
+			}
+		})
+	}
+}
diff --git a/internal/app/tfsec/parser/block_test.go b/internal/app/tfsec/parser/block_test.go
new file mode 100644
index 000000000..a4e269f04
--- /dev/null
+++ b/internal/app/tfsec/parser/block_test.go
@@ -0,0 +1,103 @@
+package parser
+
+import (
+	"github.com/stretchr/testify/assert"
+	"path/filepath"
+	"testing"
+)
+
+func Test_IsPresentCheckOnBlock(t *testing.T) {
+	var tests = []struct {
+		name              string
+		source            string
+		expectedAttribute string
+	}{
+		{
+			name: "expected attribute is present",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+}`,
+			expectedAttribute: "bucket_name",
+		},
+		{
+			name: "expected acl attribute is present",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+}`,
+			expectedAttribute: "acl",
+		},
+		{
+			name: "expected acl attribute is present",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+	logging {
+		target_bucket = aws_s3_bucket.log_bucket.id
+		target_prefix = "log/"
+	}
+}`,
+			expectedAttribute: "logging",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			blocks := createBlocksFromSource(test.source)
+			for _, block := range blocks {
+				assert.Equal(t, block.HasChild(test.expectedAttribute), true)
+				assert.Equal(t, !block.HasChild(test.expectedAttribute), false)
+			}
+		})
+	}
+}
+
+func Test_IsNotPresentCheckOnBlock(t *testing.T) {
+	var tests = []struct {
+		name              string
+		source            string
+		expectedAttribute string
+	}{
+		{
+			name: "expected attribute is not present",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	
+}`,
+			expectedAttribute: "acl",
+		},
+		{
+			name: "expected acl attribute is not present",
+			source: `
+resource "aws_s3_bucket" "my-bucket" {
+ 	bucket_name = "bucketName"
+	acl = "public-read"
+	
+}`,
+			expectedAttribute: "logging",
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			blocks := createBlocksFromSource(test.source)
+			for _, block := range blocks {
+				assert.Equal(t, block.HasChild(test.expectedAttribute), false)
+				assert.Equal(t, !block.HasChild(test.expectedAttribute), true)
+			}
+		})
+	}
+}
+
+func createBlocksFromSource(source string) []*Block {
+	path := createTestFile("test.tf", source)
+	blocks, err := New().ParseDirectory(filepath.Dir(path), nil, "")
+	if err != nil {
+		panic(err)
+	}
+	return blocks
+}
diff --git a/internal/app/tfsec/parser/parser_test.go b/internal/app/tfsec/parser/parser_test.go
index c463caa8a..1f1307f91 100644
--- a/internal/app/tfsec/parser/parser_test.go
+++ b/internal/app/tfsec/parser/parser_test.go
@@ -4,6 +4,7 @@ import (
 	"io/ioutil"
 	"os"
 	"path/filepath"
+	"sort"
 	"testing"
 
 	"github.com/zclconf/go-cty/cty"
@@ -72,6 +73,11 @@ data "cats_cat" "the-cats-mother" {
 
 	// resources
 	resourceBlocks := blocks.OfType("resource")
+
+	sort.Slice(resourceBlocks, func(i, j int) bool {
+		return resourceBlocks[i].Labels()[0] < resourceBlocks[j].Labels()[0]
+	})
+
 	require.Len(t, resourceBlocks, 2)
 	require.Len(t, resourceBlocks[0].Labels(), 2)
 
@@ -141,13 +147,19 @@ output "result" {
 	assert.Equal(t, "ok", inputAttr.Value().AsString())
 
 	outputs := blocks.OfType("output")
-	require.Len(t, outputs, 1)
-	output := outputs[0]
-	assert.Equal(t, "output.result", output.Name())
-	valAttr := output.GetAttribute("value")
-	require.NotNil(t, valAttr)
-	require.Equal(t, cty.String, valAttr.Type())
-	assert.Equal(t, "ok", valAttr.Value().AsString())
+	require.Len(t, outputs, 2)
+	for _, output := range outputs {
+		if output.inModule {
+			assert.Equal(t, "module.my-mod.result", output.Name())
+		} else {
+			assert.Equal(t, "output.result", output.Name())
+		}
+		valAttr := output.GetAttribute("value")
+		require.NotNil(t, valAttr)
+		require.Equal(t, cty.String, valAttr.Type())
+		assert.Equal(t, "ok", valAttr.Value().AsString())
+	}
+
 }
 
 func Test_NestedParentModule(t *testing.T) {
@@ -192,13 +204,18 @@ output "result" {
 	assert.Equal(t, "ok", inputAttr.Value().AsString())
 
 	outputs := blocks.OfType("output")
-	require.Len(t, outputs, 1)
-	output := outputs[0]
-	assert.Equal(t, "output.result", output.Name())
-	valAttr := output.GetAttribute("value")
-	require.NotNil(t, valAttr)
-	require.Equal(t, cty.String, valAttr.Type())
-	assert.Equal(t, "ok", valAttr.Value().AsString())
+	require.Len(t, outputs, 2)
+	for _, output := range outputs {
+		if output.inModule {
+			assert.Equal(t, "module.my-mod.result", output.Name())
+		} else {
+			assert.Equal(t, "output.result", output.Name())
+		}
+		valAttr := output.GetAttribute("value")
+		require.NotNil(t, valAttr)
+		require.Equal(t, cty.String, valAttr.Type())
+		assert.Equal(t, "ok", valAttr.Value().AsString())
+	}
 }
 
 func createTestFile(filename, contents string) string {