Repository: aquasecurity/tfsec
Pull request #1026 (2021-08-16 09:54:20)

Add mssql alert checks
Add alert checks for for mssql security alerts
Add supporting skeleton changes



Patch:
diff --git a/Makefile b/Makefile
index 5ce86a373..bd11321ca 100644
--- a/Makefile
+++ b/Makefile
@@ -47,7 +47,7 @@ tagger:
 .PHONY: cyclo
 cyclo:
 	which gocyclo || go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
-	gocyclo -over 15 -ignore 'vendor/|funcs/' .
+	gocyclo -over 15 -ignore 'vendor/|funcs|cmd/tfsec-skeleton' .
 
 .PHONY: vet
 vet:
diff --git a/cmd/tfsec-skeleton/csv.go b/cmd/tfsec-skeleton/csv.go
index 2b93efa1f..f4d35ae8d 100644
--- a/cmd/tfsec-skeleton/csv.go
+++ b/cmd/tfsec-skeleton/csv.go
@@ -32,12 +32,12 @@ func generateFromCSV(path string) ([]*Input, error) {
 	for i, record := range rawCSVdata {
 
 		// not ready
-		if record[0] != "1" {
+		if record[0] != "1" && record[0] != "TRUE" {
 			continue
 		}
 
 		// already done
-		if record[1] == "1" {
+		if record[1] == "1" || record[1] == "TRUE" {
 			continue
 		}
 
diff --git a/cmd/tfsec-skeleton/requirements/attributes.go b/cmd/tfsec-skeleton/requirements/attributes.go
index 653fd28af..5462db4f8 100644
--- a/cmd/tfsec-skeleton/requirements/attributes.go
+++ b/cmd/tfsec-skeleton/requirements/attributes.go
@@ -154,6 +154,14 @@ func (a *attributeBase) getMessageForComparison() string {
 		return fmt.Sprintf("Resource '%%s' does not set %s", a.dotPath)
 	case ComparisonNotDefined:
 		return fmt.Sprintf("Resource '%%s' sets %s", a.dotPath)
+	case ComparisonIsEmpty:
+		return fmt.Sprintf("Resource '%%s' has attribute %s that is not empty", a.dotPath)
+	case ComparisonNotEmpty:
+		return fmt.Sprintf("Resource '%%s' has attribute %s that is empty", a.dotPath)
+	case ComparisonIsTrue:
+		return fmt.Sprintf("Resource '%%s' has attribute %s that is false", a.dotPath)
+	case ComparisonIsFalse:
+		return fmt.Sprintf("Resource '%%s' has attribute %s that is true", a.dotPath)
 
 	default:
 		panic(fmt.Sprintf("comparison '%s' is not supported", a.comparison))
diff --git a/cmd/tfsec-skeleton/requirements/comparison.go b/cmd/tfsec-skeleton/requirements/comparison.go
index 2263c602a..4335f0630 100644
--- a/cmd/tfsec-skeleton/requirements/comparison.go
+++ b/cmd/tfsec-skeleton/requirements/comparison.go
@@ -16,6 +16,10 @@ const (
 	ComparisonNotContains        Comparison = "notcontains"
 	ComparisonDefined            Comparison = "defined"
 	ComparisonNotDefined         Comparison = "notdefined"
+	ComparisonIsEmpty            Comparison = "empty"
+	ComparisonNotEmpty           Comparison = "notempty"
+	ComparisonIsTrue             Comparison = "istrue"
+	ComparisonIsFalse            Comparison = "isfalse"
 )
 
 func (c Comparison) Reverse() Comparison {
@@ -44,6 +48,14 @@ func (c Comparison) Reverse() Comparison {
 		return ComparisonNotDefined
 	case ComparisonNotDefined:
 		return ComparisonDefined
+	case ComparisonIsEmpty:
+		return ComparisonNotEmpty
+	case ComparisonNotEmpty:
+		return ComparisonIsEmpty
+	case ComparisonIsTrue:
+		return ComparisonIsFalse
+	case ComparisonIsFalse:
+		return ComparisonIsTrue
 	default:
 		return ComparisonNone
 	}
diff --git a/cmd/tfsec-skeleton/requirements/requirements.go b/cmd/tfsec-skeleton/requirements/requirements.go
index 5a12098cb..96b763d3d 100644
--- a/cmd/tfsec-skeleton/requirements/requirements.go
+++ b/cmd/tfsec-skeleton/requirements/requirements.go
@@ -113,6 +113,14 @@ func buildComparisonForValue(value interface{}, comparison Comparison) string {
 		return `IsResolvable()`
 	case ComparisonNotDefined:
 		return `IsNotResolvable()`
+	case ComparisonIsEmpty:
+		return `IsEmpty()`
+	case ComparisonNotEmpty:
+		return `IsNotEmpty()`
+	case ComparisonIsTrue:
+		return `IsTrue()`
+	case ComparisonIsFalse:
+		return `IsFalse()`
 	}
 
 	switch t := value.(type) {
diff --git a/internal/app/tfsec/block/attribute.go b/internal/app/tfsec/block/attribute.go
index b930a219b..192dded34 100644
--- a/internal/app/tfsec/block/attribute.go
+++ b/internal/app/tfsec/block/attribute.go
@@ -24,6 +24,7 @@ type Attribute interface {
 	IsTrue() bool
 	IsFalse() bool
 	IsEmpty() bool
+	IsNotEmpty() bool
 	IsNil() bool
 	IsNotNil() bool
 	MapValue(mapKey string) cty.Value
diff --git a/internal/app/tfsec/block/hclattribute.go b/internal/app/tfsec/block/hclattribute.go
index d156df311..a1036ca3c 100644
--- a/internal/app/tfsec/block/hclattribute.go
+++ b/internal/app/tfsec/block/hclattribute.go
@@ -450,6 +450,10 @@ func (attr *HCLAttribute) IsEmpty() bool {
 	return true
 }
 
+func (attr *HCLAttribute) IsNotEmpty() bool {
+	return !attr.IsEmpty()
+}
+
 func (attr *HCLAttribute) isNullAttributeEmpty() bool {
 	if attr == nil {
 		return false
diff --git a/internal/app/tfsec/rules/azure/mssql/all_threat_alerts_enabled_rule.go b/internal/app/tfsec/rules/azure/mssql/all_threat_alerts_enabled_rule.go
new file mode 100644
index 000000000..5ffedd006
--- /dev/null
+++ b/internal/app/tfsec/rules/azure/mssql/all_threat_alerts_enabled_rule.go
@@ -0,0 +1,70 @@
+package mssql
+
+// ATTENTION!
+// This rule was autogenerated!
+// Before making changes, consider updating the generator.
+
+import (
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/block"
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/scanner"
+	"github.com/aquasecurity/tfsec/pkg/provider"
+	"github.com/aquasecurity/tfsec/pkg/result"
+	"github.com/aquasecurity/tfsec/pkg/rule"
+	"github.com/aquasecurity/tfsec/pkg/severity"
+)
+
+func init() {
+	scanner.RegisterCheckRule(rule.Rule{
+		Provider:  provider.AzureProvider,
+		Service:   "mssql",
+		ShortCode: "all-threat-alerts-enabled",
+		Documentation: rule.RuleDocumentation{
+			Summary:     "No threat detections are set",
+			Explanation: `SQL Server can alert for security issues including SQL Injection, vulnerabilities, access anomalies and data exfiltration. Ensure none of these are disabled to benefit from the best protection`,
+			Impact:      "Disabling threat alerts means you are not getting the full benefit of server security protection",
+			Resolution:  "Use all provided threat alerts",
+			BadExample: []string{`
+resource "azurerm_mssql_server_security_alert_policy" "bad_example" {
+  resource_group_name        = azurerm_resource_group.example.name
+  server_name                = azurerm_sql_server.example.name
+  state                      = "Enabled"
+  storage_endpoint           = azurerm_storage_account.example.primary_blob_endpoint
+  storage_account_access_key = azurerm_storage_account.example.primary_access_key
+  disabled_alerts = [
+    "Sql_Injection",
+    "Data_Exfiltration"
+  ]
+  retention_days = 20
+}
+`},
+			GoodExample: []string{`
+resource "azurerm_mssql_server_security_alert_policy" "good_example" {
+  resource_group_name        = azurerm_resource_group.example.name
+  server_name                = azurerm_sql_server.example.name
+  state                      = "Enabled"
+  storage_endpoint           = azurerm_storage_account.example.primary_blob_endpoint
+  storage_account_access_key = azurerm_storage_account.example.primary_access_key
+  disabled_alerts = []
+  retention_days = 20
+}
+`},
+			Links: []string{
+				"https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/mssql_server_security_alert_policy#disabled_alerts",
+			},
+		},
+		RequiredTypes: []string{
+			"resource",
+		},
+		RequiredLabels: []string{
+			"azurerm_mssql_server_security_alert_policy",
+		},
+		DefaultSeverity: severity.Medium,
+		CheckFunc: func(set result.Set, resourceBlock block.Block, module block.Module) {
+			if disabledAlertsAttr := resourceBlock.GetAttribute("disabled_alerts"); disabledAlertsAttr.IsNotEmpty() {
+				set.AddResult().
+					WithDescription("Resource '%s' has attribute disabled_alerts that is not empty", resourceBlock.FullName()).
+					WithAttribute(disabledAlertsAttr)
+			}
+		},
+	})
+}
diff --git a/internal/app/tfsec/rules/azure/mssql/threat_alert_email_set_rule.go b/internal/app/tfsec/rules/azure/mssql/threat_alert_email_set_rule.go
new file mode 100644
index 000000000..86b313b0d
--- /dev/null
+++ b/internal/app/tfsec/rules/azure/mssql/threat_alert_email_set_rule.go
@@ -0,0 +1,72 @@
+package mssql
+
+// ATTENTION!
+// This rule was autogenerated!
+// Before making changes, consider updating the generator.
+
+import (
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/block"
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/scanner"
+	"github.com/aquasecurity/tfsec/pkg/provider"
+	"github.com/aquasecurity/tfsec/pkg/result"
+	"github.com/aquasecurity/tfsec/pkg/rule"
+	"github.com/aquasecurity/tfsec/pkg/severity"
+)
+
+func init() {
+	scanner.RegisterCheckRule(rule.Rule{
+		Provider:  provider.AzureProvider,
+		Service:   "mssql",
+		ShortCode: "threat-alert-email-set",
+		Documentation: rule.RuleDocumentation{
+			Summary:     "At least one email address is set for threat alerts",
+			Explanation: `SQL Server sends alerts for threat detection via email, if there are no email addresses set then mitigation will be delayed.`,
+			Impact:      "Nobody will be prompty alerted in the case of a threat being detected",
+			Resolution:  "Provide at least one email address for threat alerts",
+			BadExample: []string{`
+resource "azurerm_mssql_server_security_alert_policy" "bad_example" {
+  resource_group_name        = azurerm_resource_group.example.name
+  server_name                = azurerm_sql_server.example.name
+  state                      = "Enabled"
+  storage_endpoint           = azurerm_storage_account.example.primary_blob_endpoint
+  storage_account_access_key = azurerm_storage_account.example.primary_access_key
+  disabled_alerts = [
+    "Sql_Injection",
+    "Data_Exfiltration"
+  ]
+  email_addresses = []
+}
+`},
+			GoodExample: []string{`
+resource "azurerm_mssql_server_security_alert_policy" "good_example" {
+  resource_group_name        = azurerm_resource_group.example.name
+  server_name                = azurerm_sql_server.example.name
+  state                      = "Enabled"
+  storage_endpoint           = azurerm_storage_account.example.primary_blob_endpoint
+  storage_account_access_key = azurerm_storage_account.example.primary_access_key
+  disabled_alerts = [
+    "Sql_Injection",
+    "Data_Exfiltration"
+  ]
+  email_addresses = ["db-security@acme.org"]
+}
+`},
+			Links: []string{
+				"https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/mssql_server_security_alert_policy#email_addresses",
+			},
+		},
+		RequiredTypes: []string{
+			"resource",
+		},
+		RequiredLabels: []string{
+			"azurerm_mssql_server_security_alert_policy",
+		},
+		DefaultSeverity: severity.Medium,
+		CheckFunc: func(set result.Set, resourceBlock block.Block, module block.Module) {
+			if emailAddressesAttr := resourceBlock.GetAttribute("email_addresses"); emailAddressesAttr.IsNil() || emailAddressesAttr.IsEmpty() {
+				set.AddResult().
+					WithDescription("Resource '%s' does not specified emails_addresses for alerts", resourceBlock.FullName())
+			}
+		},
+	})
+}
diff --git a/internal/app/tfsec/rules/azure/mssql/threat_alert_email_to_owner_rule.go b/internal/app/tfsec/rules/azure/mssql/threat_alert_email_to_owner_rule.go
new file mode 100644
index 000000000..583a77881
--- /dev/null
+++ b/internal/app/tfsec/rules/azure/mssql/threat_alert_email_to_owner_rule.go
@@ -0,0 +1,72 @@
+package mssql
+
+// ATTENTION!
+// This rule was autogenerated!
+// Before making changes, consider updating the generator.
+
+import (
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/block"
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/scanner"
+	"github.com/aquasecurity/tfsec/pkg/provider"
+	"github.com/aquasecurity/tfsec/pkg/result"
+	"github.com/aquasecurity/tfsec/pkg/rule"
+	"github.com/aquasecurity/tfsec/pkg/severity"
+)
+
+func init() {
+	scanner.RegisterCheckRule(rule.Rule{
+		Provider:  provider.AzureProvider,
+		Service:   "mssql",
+		ShortCode: "threat-alert-email-to-owner",
+		Documentation: rule.RuleDocumentation{
+			Summary:     "Security threat alerts go to subcription owners and co-administrators",
+			Explanation: `Subscription owners should be notified when there are security alerts. By ensuring the administrators of the account have been notified they can quickly assist in any required remediation`,
+			Impact:      "Administrators and subscription owners may have a delayed response",
+			Resolution:  "Enable email to subscription owners",
+			BadExample: []string{`
+resource "azurerm_mssql_server_security_alert_policy" "bad_example" {
+  resource_group_name        = azurerm_resource_group.example.name
+  server_name                = azurerm_sql_server.example.name
+  state                      = "Enabled"
+  storage_endpoint           = azurerm_storage_account.example.primary_blob_endpoint
+  storage_account_access_key = azurerm_storage_account.example.primary_access_key
+  disabled_alerts = [
+  ]
+  email_account_admins = false
+}
+`},
+			GoodExample: []string{`
+resource "azurerm_mssql_server_security_alert_policy" "good_example" {
+  resource_group_name        = azurerm_resource_group.example.name
+  server_name                = azurerm_sql_server.example.name
+  state                      = "Enabled"
+  storage_endpoint           = azurerm_storage_account.example.primary_blob_endpoint
+  storage_account_access_key = azurerm_storage_account.example.primary_access_key
+  disabled_alerts = []
+
+  email_account_admins = true
+}
+`},
+			Links: []string{
+				"https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/mssql_server_security_alert_policy#email_account_admins",
+			},
+		},
+		RequiredTypes: []string{
+			"resource",
+		},
+		RequiredLabels: []string{
+			"azurerm_mssql_server_security_alert_policy",
+		},
+		DefaultSeverity: severity.Low,
+		CheckFunc: func(set result.Set, resourceBlock block.Block, module block.Module) {
+			if emailAccountAdminsAttr := resourceBlock.GetAttribute("email_account_admins"); emailAccountAdminsAttr.IsNil() { // alert on use of default value
+				set.AddResult().
+					WithDescription("Resource '%s' uses default value for email_account_admins", resourceBlock.FullName())
+			} else if emailAccountAdminsAttr.IsFalse() {
+				set.AddResult().
+					WithDescription("Resource '%s' has attribute email_account_admins that is false", resourceBlock.FullName()).
+					WithAttribute(emailAccountAdminsAttr)
+			}
+		},
+	})
+}
diff --git a/internal/app/tfsec/rules/init.go b/internal/app/tfsec/rules/init.go
index 260d7e13d..21a39e3b4 100644
--- a/internal/app/tfsec/rules/init.go
+++ b/internal/app/tfsec/rules/init.go
@@ -41,6 +41,8 @@ import (
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/aws/vpc"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/aws/workspace"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/appservice"
+
+	// _ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/authorization"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/compute"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/container"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/database"
@@ -48,6 +50,7 @@ import (
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/datalake"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/keyvault"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/monitor"
+	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/mssql"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/network"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/securitycenter"
 	_ "github.com/aquasecurity/tfsec/internal/app/tfsec/rules/azure/storage"


Test patch:
diff --git a/internal/app/tfsec/rules/azure/mssql/all_threat_alerts_enabled_rule_test.go b/internal/app/tfsec/rules/azure/mssql/all_threat_alerts_enabled_rule_test.go
new file mode 100644
index 000000000..e28f3b69e
--- /dev/null
+++ b/internal/app/tfsec/rules/azure/mssql/all_threat_alerts_enabled_rule_test.go
@@ -0,0 +1,53 @@
+package mssql
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/scanner"
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/testutil"
+)
+
+func Test_AzureAllThreatAlertsEnabled_FailureExamples(t *testing.T) {
+	expectedCode := "azure-mssql-all-threat-alerts-enabled"
+
+	rule, err := scanner.GetRuleById(expectedCode)
+	if err != nil {
+		t.Fatalf("Rule not found: %s", expectedCode)
+	}
+	for i, badExample := range rule.Documentation.BadExample {
+		t.Logf("Running bad example for '%s' #%d", expectedCode, i+1)
+		if strings.TrimSpace(badExample) == "" {
+			t.Fatalf("bad example code not provided for %s", rule.ID())
+		}
+		defer func() {
+			if err := recover(); err != nil {
+				t.Fatalf("Scan (bad) failed: %s", err)
+			}
+		}()
+		results := testutil.ScanHCL(badExample, t)
+		testutil.AssertCheckCode(t, rule.ID(), "", results)
+	}
+}
+
+func Test_AzureAllThreatAlertsEnabled_SuccessExamples(t *testing.T) {
+	expectedCode := "azure-mssql-all-threat-alerts-enabled"
+
+	rule, err := scanner.GetRuleById(expectedCode)
+	if err != nil {
+		t.Fatalf("Rule not found: %s", expectedCode)
+	}
+	for i, example := range rule.Documentation.GoodExample {
+		t.Logf("Running good example for '%s' #%d", expectedCode, i+1)
+		if strings.TrimSpace(example) == "" {
+			t.Fatalf("good example code not provided for %s", rule.ID())
+		}
+		defer func() {
+			if err := recover(); err != nil {
+				t.Fatalf("Scan (good) failed: %s", err)
+			}
+		}()
+		results := testutil.ScanHCL(example, t)
+		testutil.AssertCheckCode(t, "", rule.ID(), results)
+	}
+}
diff --git a/internal/app/tfsec/rules/azure/mssql/threat_alert_email_set_rule_test.go b/internal/app/tfsec/rules/azure/mssql/threat_alert_email_set_rule_test.go
new file mode 100644
index 000000000..a48ff5fce
--- /dev/null
+++ b/internal/app/tfsec/rules/azure/mssql/threat_alert_email_set_rule_test.go
@@ -0,0 +1,53 @@
+package mssql
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/scanner"
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/testutil"
+)
+
+func Test_AzureThreatAlertEmailSet_FailureExamples(t *testing.T) {
+	expectedCode := "azure-mssql-threat-alert-email-set"
+
+	rule, err := scanner.GetRuleById(expectedCode)
+	if err != nil {
+		t.Fatalf("Rule not found: %s", expectedCode)
+	}
+	for i, badExample := range rule.Documentation.BadExample {
+		t.Logf("Running bad example for '%s' #%d", expectedCode, i+1)
+		if strings.TrimSpace(badExample) == "" {
+			t.Fatalf("bad example code not provided for %s", rule.ID())
+		}
+		defer func() {
+			if err := recover(); err != nil {
+				t.Fatalf("Scan (bad) failed: %s", err)
+			}
+		}()
+		results := testutil.ScanHCL(badExample, t)
+		testutil.AssertCheckCode(t, rule.ID(), "", results)
+	}
+}
+
+func Test_AzureThreatAlertEmailSet_SuccessExamples(t *testing.T) {
+	expectedCode := "azure-mssql-threat-alert-email-set"
+
+	rule, err := scanner.GetRuleById(expectedCode)
+	if err != nil {
+		t.Fatalf("Rule not found: %s", expectedCode)
+	}
+	for i, example := range rule.Documentation.GoodExample {
+		t.Logf("Running good example for '%s' #%d", expectedCode, i+1)
+		if strings.TrimSpace(example) == "" {
+			t.Fatalf("good example code not provided for %s", rule.ID())
+		}
+		defer func() {
+			if err := recover(); err != nil {
+				t.Fatalf("Scan (good) failed: %s", err)
+			}
+		}()
+		results := testutil.ScanHCL(example, t)
+		testutil.AssertCheckCode(t, "", rule.ID(), results)
+	}
+}
diff --git a/internal/app/tfsec/rules/azure/mssql/threat_alert_email_to_owner_rule_test.go b/internal/app/tfsec/rules/azure/mssql/threat_alert_email_to_owner_rule_test.go
new file mode 100644
index 000000000..44d2af345
--- /dev/null
+++ b/internal/app/tfsec/rules/azure/mssql/threat_alert_email_to_owner_rule_test.go
@@ -0,0 +1,53 @@
+package mssql
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/scanner"
+	"github.com/aquasecurity/tfsec/internal/app/tfsec/testutil"
+)
+
+func Test_AzureThreatAlertEmailToOwner_FailureExamples(t *testing.T) {
+	expectedCode := "azure-mssql-threat-alert-email-to-owner"
+
+	rule, err := scanner.GetRuleById(expectedCode)
+	if err != nil {
+		t.Fatalf("Rule not found: %s", expectedCode)
+	}
+	for i, badExample := range rule.Documentation.BadExample {
+		t.Logf("Running bad example for '%s' #%d", expectedCode, i+1)
+		if strings.TrimSpace(badExample) == "" {
+			t.Fatalf("bad example code not provided for %s", rule.ID())
+		}
+		defer func() {
+			if err := recover(); err != nil {
+				t.Fatalf("Scan (bad) failed: %s", err)
+			}
+		}()
+		results := testutil.ScanHCL(badExample, t)
+		testutil.AssertCheckCode(t, rule.ID(), "", results)
+	}
+}
+
+func Test_AzureThreatAlertEmailToOwner_SuccessExamples(t *testing.T) {
+	expectedCode := "azure-mssql-threat-alert-email-to-owner"
+
+	rule, err := scanner.GetRuleById(expectedCode)
+	if err != nil {
+		t.Fatalf("Rule not found: %s", expectedCode)
+	}
+	for i, example := range rule.Documentation.GoodExample {
+		t.Logf("Running good example for '%s' #%d", expectedCode, i+1)
+		if strings.TrimSpace(example) == "" {
+			t.Fatalf("good example code not provided for %s", rule.ID())
+		}
+		defer func() {
+			if err := recover(); err != nil {
+				t.Fatalf("Scan (good) failed: %s", err)
+			}
+		}()
+		results := testutil.ScanHCL(example, t)
+		testutil.AssertCheckCode(t, "", rule.ID(), results)
+	}
+}