Repository: tfsec/tfsec
Pull request #526 (2020-11-24 21:11:09)

Additional custom checks for `notContains` and `requiresPresence`
Add two actions for custom checks:

* `notContains`, which is useful for policy documents on AWS to exclude certain patterns such as `kms:*`.
* `requiresPresence` that requires that the resource mentioned in the `matchSepc` is also present

owenrumney: thanks @felipesere  - I really like this and thanks for the time taken to make sure the docs are in order.

Patch:
diff --git a/docs-website/_docs/custom_checks.md b/docs-website/_docs/custom_checks.md
index 76034919e..d3a6e6594 100644
--- a/docs-website/_docs/custom_checks.md
+++ b/docs-website/_docs/custom_checks.md
@@ -154,6 +154,21 @@ For example, if you want to ensure that the `CostCentre` exists, you might use t
 }
 ```
 
+##### notContains
+The `notContains` 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, you want to make sure that an `action` does not contain `kms:*` you might use the following `MatchSpec`:
+
+```json
+"matchSpec" : {
+  "name": "action",
+  "action": "notContains",
+  "value": "kms:*"
+}
+```
+
 ##### 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.
@@ -257,6 +272,18 @@ The `isNone` check action passes when the attribute value cannot be found in the
 }
 ```
 
+##### requiresPresence
+The `requiresPresence` checks that the resouce in `name` is also present in the Terraform code.
+
+If you wanted to ensure that `aws_vpc_flowlogs` is present if there is a `aws_vpc`, you might use the following `matchSpec`:
+
+```
+"matchSpec" : {
+  "action": "requiresPresence",
+  "name": "aws_vpc_flowlogs"
+}
+```
+
 ## 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`. 
 
diff --git a/internal/app/tfsec/custom/custom_check.go b/internal/app/tfsec/custom/custom_check.go
index d88737e61..00e7ece9d 100644
--- a/internal/app/tfsec/custom/custom_check.go
+++ b/internal/app/tfsec/custom/custom_check.go
@@ -15,12 +15,14 @@ var ValidCheckActions = []CheckAction{
 	StartsWith,
 	EndsWith,
 	Contains,
+	NotContains,
 	Equals,
 	LessThan,
 	LessThanOrEqualTo,
 	GreaterThan,
 	GreaterThanOrEqualTo,
 	RegexMatches,
+	RequiresPresence,
 	IsAny,
 	IsNone,
 }
@@ -46,6 +48,9 @@ const EndsWith CheckAction = "endsWith"
 // Contains checks that the named child attribute has a value in the map, list or attribute
 const Contains CheckAction = "contains"
 
+// NotContains checks that the named child attribute does not have a value in the map, list or attribute
+const NotContains CheckAction = "notContains"
+
 // Contains checks that the named child attribute has a value equal to the check value
 const Equals CheckAction = "equals"
 
@@ -70,6 +75,9 @@ const GreaterThan CheckAction = "greaterThan"
 // GreaterThanOrEqualTo checks that the named attribute value is greater than or equal to the check value
 const GreaterThanOrEqualTo CheckAction = "greaterThanOrEqualTo"
 
+// RequiresPresence checks that a second resource is present
+const RequiresPresence CheckAction = "requiresPresence"
+
 // MatchSpec specifies the checks that should be performed
 type MatchSpec struct {
 	Name            string      `json:"name,omitempty"`
diff --git a/internal/app/tfsec/custom/processing.go b/internal/app/tfsec/custom/processing.go
index 6fadc4e70..dd7aa2905 100644
--- a/internal/app/tfsec/custom/processing.go
+++ b/internal/app/tfsec/custom/processing.go
@@ -2,7 +2,6 @@ package custom
 
 import (
 	"fmt"
-
 	"github.com/tfsec/tfsec/internal/app/tfsec/parser"
 	"github.com/tfsec/tfsec/internal/app/tfsec/scanner"
 )
@@ -43,6 +42,13 @@ var matchFunctions = map[CheckAction]func(*parser.Block, *MatchSpec) bool{
 		}
 		return attribute.Contains(spec.MatchValue)
 	},
+	NotContains: func(block *parser.Block, spec *MatchSpec) bool {
+		attribute := block.GetAttribute(spec.Name)
+		if attribute == nil {
+			return spec.IgnoreUndefined
+		}
+		return !attribute.Contains(spec.MatchValue)
+	},
 	Equals: func(block *parser.Block, spec *MatchSpec) bool {
 		attribute := block.GetAttribute(spec.Name)
 		if attribute == nil {
@@ -111,9 +117,9 @@ func processFoundChecks(checks ChecksFile) {
 				Provider:       "custom",
 				RequiredTypes:  customCheck.RequiredTypes,
 				RequiredLabels: customCheck.RequiredLabels,
-				CheckFunc: func(check *scanner.Check, rootBlock *parser.Block, _ *scanner.Context) []scanner.Result {
+				CheckFunc: func(check *scanner.Check, rootBlock *parser.Block, ctx *scanner.Context) []scanner.Result {
 					matchSpec := customCheck.MatchSpec
-					if !evalMatchSpec(rootBlock, matchSpec) {
+					if !evalMatchSpec(rootBlock, matchSpec, ctx) {
 						return []scanner.Result{
 							check.NewResult(
 								fmt.Sprintf("Custom check failed for resource %s. %s", rootBlock.FullName(), customCheck.ErrorMessage),
@@ -129,7 +135,7 @@ func processFoundChecks(checks ChecksFile) {
 	}
 }
 
-func evalMatchSpec(block *parser.Block, spec *MatchSpec) bool {
+func evalMatchSpec(block *parser.Block, spec *MatchSpec, ctx *scanner.Context) bool {
 	if block == nil {
 		return false
 	}
@@ -140,17 +146,28 @@ func evalMatchSpec(block *parser.Block, spec *MatchSpec) bool {
 	if spec.Action == RegexMatches && !matchFunctions[RegexMatches](block, spec) {
 		return true
 	}
+
+	if spec.Action == RequiresPresence {
+		return resourceFound(spec, ctx)
+	}
+
 	evalResult = matchFunctions[spec.Action](block, spec)
 
 	if spec.SubMatch != nil {
 		if block.HasBlock(spec.Name) {
 			block = block.GetBlock(spec.Name)
 		}
-		evalResult = evalMatchSpec(block, spec.SubMatch)
+		evalResult = evalMatchSpec(block, spec.SubMatch, nil)
 	}
 	return evalResult
 }
 
+func resourceFound(spec *MatchSpec, ctx *scanner.Context) bool {
+	val := fmt.Sprintf("%v", spec.Name)
+	byType := ctx.GetResourcesByType(val)
+	return len(byType) > 0
+}
+
 func unpackInterfaceToInterfaceSlice(t interface{}) []interface{} {
 	switch t := t.(type) {
 	case []interface{}:


Test patch:
diff --git a/internal/app/tfsec/custom/processing_test.go b/internal/app/tfsec/custom/processing_test.go
new file mode 100644
index 000000000..96d2b292e
--- /dev/null
+++ b/internal/app/tfsec/custom/processing_test.go
@@ -0,0 +1,94 @@
+package custom
+
+import (
+	"encoding/json"
+	"fmt"
+	"github.com/stretchr/testify/assert"
+	"github.com/tfsec/tfsec/internal/app/tfsec/parser"
+	"github.com/tfsec/tfsec/internal/app/tfsec/scanner"
+	"io/ioutil"
+	"os"
+	"strings"
+	"testing"
+)
+
+func init() {
+	givenCheck(`{
+  "checks": [
+    {
+      "code": "DP006",
+      "description": "VPC flow logs must be enabled",
+      "requiredTypes": [
+        "resource"
+      ],
+      "requiredLabels": [
+        "aws_vpc"
+      ],
+      "severity": "ERROR",
+      "matchSpec": {
+        "action": "requiresPresence",
+		"name": "aws_flow_log"
+      },
+      "errorMessage": "VPCs should have an aws_flow_log associated with them",
+      "relatedLinks": []
+    }
+  ]
+}
+`)
+}
+
+func TestRequiresPresenceWithResourcePresent(t *testing.T) {
+	scanResults := scanTerraform(t, `
+resource "aws_vpc" "main" {
+  cidr_block       = "10.0.0.0/16"
+  instance_tenancy = "default"
+
+  tags = {
+    Name = "main"
+  }
+}
+
+resource "aws_flow_log" "example" {
+  iam_role_arn    = aws_iam_role.example.arn
+  log_destination = aws_cloudwatch_log_group.example.arn
+  traffic_type    = "ALL"
+  vpc_id          = aws_vpc.example.id
+}
+`)
+	assert.Len(t, scanResults, 0)
+}
+func TestRequiresPresenceWithResourceMissing(t *testing.T) {
+	scanResults := scanTerraform(t, `
+resource "aws_vpc" "main" {
+  cidr_block       = "10.0.0.0/16"
+  instance_tenancy = "default"
+
+  tags = {
+    Name = "main"
+  }
+}
+`)
+	assert.Len(t, scanResults, 1)
+}
+
+func givenCheck(jsonContent string) {
+	var checksfile ChecksFile
+	err := json.NewDecoder(strings.NewReader(jsonContent)).Decode(&checksfile)
+	if err != nil {
+		panic(err)
+	}
+	processFoundChecks(checksfile)
+}
+
+func scanTerraform(t *testing.T, mainTf string) []scanner.Result {
+	dirName, err := ioutil.TempDir("", "tfsec-testing-")
+	assert.NoError(t, err)
+
+	err = ioutil.WriteFile(fmt.Sprintf("%s/%s", dirName, "main.tf"), []byte(mainTf), os.ModePerm)
+	assert.NoError(t, err)
+
+	blocks, err := parser.New(dirName, "").ParseDirectory()
+	assert.NoError(t, err)
+
+	return scanner.New().Scan(blocks, []string{})
+}