Repository: tenable/terrascan (language: python)

Issue:
Scan for terraform doesn't error out if a module definition refers to a directory with no tf files
* terrascan version: 1.6.0
* Operating System: All

### Description
If a module definition in a terraform file has `source` as a local directory which doesn't contain any tf config files, scan should result in an error.

### What I Did

```
terrascan scan -i terraform -d <dir> --non-recursive
```

The directory contains a tf file that defines a module that has a local source.

```
module "ec2" {
  source = "../../"

  instance_count = 1

  ...
}

```



Discussion / hints:

Fix patch:
diff --git a/pkg/iac-providers/terraform/commons/load-dir.go b/pkg/iac-providers/terraform/commons/load-dir.go
index dec54df0f..6b5ccb92c 100644
--- a/pkg/iac-providers/terraform/commons/load-dir.go
+++ b/pkg/iac-providers/terraform/commons/load-dir.go
@@ -120,7 +120,7 @@ func (t TerraformDirectoryLoader) loadDirRecursive(dirList []string) (output.All
 		if diags.HasErrors() {
 			// log a warn message in this case because there are errors in
 			// loading the config dir, and continue with other directories
-			errMessage := fmt.Sprintf("failed to build unified config. errors:\n%+v\n", diags)
+			errMessage := fmt.Sprintf("failed to build unified config. errors:\n%+v\n", getErrorMessagesFromDiagnostics(diags))
 			zap.S().Warnf(errMessage)
 			t.addError(errMessage, dir)
 			continue
@@ -235,7 +235,7 @@ func (t TerraformDirectoryLoader) loadDirNonRecursive() (output.AllResourceConfi
 	if diags.HasErrors() {
 		// log a warn message in this case because there are errors in
 		// loading the config dir, and continue with other directories
-		errMessage := fmt.Sprintf("failed to build unified config. errors:\n%+v\n", diags)
+		errMessage := fmt.Sprintf("failed to build unified config. errors:\n%+v\n", getErrorMessagesFromDiagnostics(diags))
 		zap.S().Warnf(errMessage)
 		return nil, multierror.Append(t.errIacLoadDirs, results.DirScanErr{IacType: "terraform", Directory: t.absRootDir, ErrMessage: ErrBuildTFConfigDir.Error()})
 	}
@@ -319,8 +319,11 @@ func (t TerraformDirectoryLoader) buildUnifiedConfig(rootMod *hclConfigs.Module,
 		func(req *hclConfigs.ModuleRequest) (*hclConfigs.Module, *version.Version, hcl.Diagnostics) {
 
 			// figure out path sub module directory, if it's remote then download it locally
-			var pathToModule string
-			var err error
+			var (
+				pathToModule   string
+				err            error
+				moduleDirDiags hcl.Diagnostics
+			)
 			if downloader.IsLocalSourceAddr(req.SourceAddr) {
 
 				pathToModule = t.processLocalSource(req)
@@ -344,6 +347,15 @@ func (t TerraformDirectoryLoader) buildUnifiedConfig(rootMod *hclConfigs.Module,
 				}
 			}
 
+			// verify whether the module source directory has any .tf config files
+			if utils.IsDirExists(pathToModule) && !t.parser.IsConfigDir(pathToModule) {
+				moduleDirDiags = append(moduleDirDiags, &hcl.Diagnostic{
+					Severity: hcl.DiagError,
+					Summary:  "Invalid module config directory",
+					Detail:   fmt.Sprintf("Module directory '%s' has no terraform config files for module %s", pathToModule, req.Name),
+				})
+				return nil, nil, moduleDirDiags
+			}
 			// load sub module directory
 			subMod, diags := t.parser.LoadConfigDir(pathToModule)
 			version, _ := version.NewVersion(fmt.Sprintf("1.0.%d", versionI))
diff --git a/pkg/utils/dir.go b/pkg/utils/dir.go
index 63fdbb0e5..b3111385f 100644
--- a/pkg/utils/dir.go
+++ b/pkg/utils/dir.go
@@ -25,3 +25,13 @@ func GetHomeDir() (terrascanDir string) {
 func GenerateTempDir() string {
 	return filepath.Join(os.TempDir(), GenRandomString(6))
 }
+
+// IsDirExists checks wether the provided directory exists or not
+func IsDirExists(dir string) bool {
+	_, err := os.Stat(dir)
+	if os.IsNotExist(err) {
+		zap.S().Debug("Directory %s does not exist.", dir)
+		return false
+	}
+	return true
+}


Test patch:
diff --git a/pkg/iac-providers/terraform/v12/load-dir_test.go b/pkg/iac-providers/terraform/v12/load-dir_test.go
index df5e344c7..262fca7cd 100644
--- a/pkg/iac-providers/terraform/v12/load-dir_test.go
+++ b/pkg/iac-providers/terraform/v12/load-dir_test.go
@@ -65,6 +65,11 @@ References to other resources during the destroy phase can cause dependency cycl
 %s:34,1-29: Duplicate resource "null_resource" configuration; A null_resource resource named "b" was already declared at testdata/destroy-provisioners/main.tf:23,1-29. Resource names must be unique per type in each module.
 `, destroyProvisionersDir, destroyProvisionersMainFile, destroyProvisionersMainFile, destroyProvisionersMainFile, destroyProvisionersMainFile)
 
+	errStringModuleSourceInvalid := fmt.Sprintf(`failed to build unified config. errors:
+<nil>: Invalid module config directory; Module directory '%s' has no terraform config files for module cloudfront
+<nil>: Invalid module config directory; Module directory '%s' has no terraform config files for module m1
+`, filepath.Join(testDataDir, "invalid-module-source"), filepath.Join(testDataDir, "invalid-module-source"))
+
 	testDirPath1 := "not-there"
 
 	testDirPath2 := filepath.Join(testDataDir, "testfile")
@@ -139,6 +144,8 @@ References to other resources during the destroy phase can cause dependency cycl
 				fmt.Errorf(invalidDirErrStringTemplate, filepath.Join(testDataDir, "deep-modules", "modules")),
 				fmt.Errorf(invalidDirErrStringTemplate, filepath.Join(testDataDir, "deep-modules", "modules", "m4", "modules")),
 				fmt.Errorf(errStringDestroyProvisioners),
+				fmt.Errorf(invalidDirErrStringTemplate, filepath.Join(testDataDir, "invalid-module-source")),
+				fmt.Errorf(errStringModuleSourceInvalid),
 				fmt.Errorf(errStringInvalidModuleConfigs),
 				fmt.Errorf(errStringInvalidModuleConfigs),
 				fmt.Errorf(testErrorString2),
@@ -159,6 +166,12 @@ References to other resources during the destroy phase can cause dependency cycl
 			tfv12:   TfV12{},
 			wantErr: multierror.Append(fmt.Errorf(testErrorString2)),
 		},
+		{
+			name:    "invalid module source directory",
+			dirPath: filepath.Join(testDataDir, "invalid-module-source", "invalid_source"),
+			tfv12:   TfV12{},
+			wantErr: multierror.Append(fmt.Errorf(errStringModuleSourceInvalid)),
+		},
 	}
 
 	for _, tt := range table {
diff --git a/pkg/iac-providers/terraform/v12/testdata/invalid-module-source/invalid_source/main.tf b/pkg/iac-providers/terraform/v12/testdata/invalid-module-source/invalid_source/main.tf
new file mode 100644
index 000000000..7019106d3
--- /dev/null
+++ b/pkg/iac-providers/terraform/v12/testdata/invalid-module-source/invalid_source/main.tf
@@ -0,0 +1,19 @@
+terraform {
+  required_version = ">= 0.12.0"
+}
+
+provider "aws" {
+  version = "2.58.0"
+  region  = "us-east-1"
+}
+
+
+module "m1" {
+    source = "../"
+    m1projectid = "tf-test-project"
+}
+
+
+module "cloudfront" {
+  source = "../"
+}
\ No newline at end of file
diff --git a/pkg/iac-providers/terraform/v14/load-dir_test.go b/pkg/iac-providers/terraform/v14/load-dir_test.go
index bd6b23d36..f9aecd525 100644
--- a/pkg/iac-providers/terraform/v14/load-dir_test.go
+++ b/pkg/iac-providers/terraform/v14/load-dir_test.go
@@ -44,8 +44,14 @@ func TestLoadIacDir(t *testing.T) {
 `, filepath.Join(testDataDir, "invalid-moduleconfigs", "cloudfront", "sub-cloudfront"))
 
 	errStringDependsOnDir := fmt.Sprintf(`failed to build unified config. errors:
-<nil>: Failed to read module directory; Module directory %s does not exist or cannot be read., and 1 other diagnostic(s)
-`, filepath.Join(testDataDir, "depends_on", "live", "log"))
+<nil>: Failed to read module directory; Module directory %s does not exist or cannot be read.
+<nil>: Failed to read module directory; Module directory %s does not exist or cannot be read.
+`, filepath.Join(testDataDir, "depends_on", "live", "log"), filepath.Join(testDataDir, "depends_on", "live", "security"))
+
+	errStringModuleSourceInvalid := fmt.Sprintf(`failed to build unified config. errors:
+<nil>: Invalid module config directory; Module directory '%s' has no terraform config files for module cloudfront
+<nil>: Invalid module config directory; Module directory '%s' has no terraform config files for module m1
+`, filepath.Join(testDataDir, "invalid-module-source"), filepath.Join(testDataDir, "invalid-module-source"))
 
 	testDirPath1 := "not-there"
 	testDirPath2 := filepath.Join(testDataDir, "testfile")
@@ -119,12 +125,20 @@ func TestLoadIacDir(t *testing.T) {
 				fmt.Errorf(invalidDirErrStringTemplate, filepath.Join(testDataDir, "deep-modules", "modules")),
 				fmt.Errorf(invalidDirErrStringTemplate, filepath.Join(testDataDir, "deep-modules", "modules", "m4", "modules")),
 				fmt.Errorf(errStringDependsOnDir),
+				fmt.Errorf(invalidDirErrStringTemplate, filepath.Join(testDataDir, "invalid-module-source")),
+				fmt.Errorf(errStringModuleSourceInvalid),
 				fmt.Errorf(errStringInvalidModuleConfigs),
 				fmt.Errorf(errStringInvalidModuleConfigs),
 				fmt.Errorf(invalidDirErrStringTemplate, filepath.Join(testDataDir, "relative-moduleconfigs")),
 				fmt.Errorf(invalidDirErrStringTemplate, filepath.Join(testDataDir, "tfjson")),
 			),
 		},
+		{
+			name:    "invalid module source directory",
+			dirPath: filepath.Join(testDataDir, "invalid-module-source", "invalid_source"),
+			tfv14:   TfV14{},
+			wantErr: multierror.Append(fmt.Errorf(errStringModuleSourceInvalid)),
+		},
 	}
 
 	for _, tt := range table {
diff --git a/pkg/iac-providers/terraform/v14/testdata/invalid-module-source/invalid_source/main.tf b/pkg/iac-providers/terraform/v14/testdata/invalid-module-source/invalid_source/main.tf
new file mode 100644
index 000000000..7019106d3
--- /dev/null
+++ b/pkg/iac-providers/terraform/v14/testdata/invalid-module-source/invalid_source/main.tf
@@ -0,0 +1,19 @@
+terraform {
+  required_version = ">= 0.12.0"
+}
+
+provider "aws" {
+  version = "2.58.0"
+  region  = "us-east-1"
+}
+
+
+module "m1" {
+    source = "../"
+    m1projectid = "tf-test-project"
+}
+
+
+module "cloudfront" {
+  source = "../"
+}
\ No newline at end of file
diff --git a/pkg/utils/dir_test.go b/pkg/utils/dir_test.go
new file mode 100644
index 000000000..5925da269
--- /dev/null
+++ b/pkg/utils/dir_test.go
@@ -0,0 +1,36 @@
+package utils
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+)
+
+func TestIsDirExists(t *testing.T) {
+	type args struct {
+		dir string
+	}
+	tests := []struct {
+		name string
+		args args
+		want bool
+	}{
+		{
+			name: "directory doesnot exists",
+			args: args{dir: "test"},
+			want: false,
+		},
+		{
+			name: "directory  exists",
+			args: args{dir: filepath.Join(os.Getenv("PWD"), "testdata")},
+			want: true,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			if got := IsDirExists(tt.args.dir); got != tt.want {
+				t.Errorf("IsDirExists() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}