Repository: cloud-custodian/cloud-custodian

Issue:
c7n-left - finding in module should record caller
at the moment the finding is against the module source, but knowing the caller is key to finding the user and parameterization of the module that likely caused the error, this likely needs to be a chain for nested module references.

```demo.tf
module "db" {
  source  = "terraform-aws-modules/rds/aws"
  version = "~> 3.0"

  identifier = "demodb"

  engine            = "mysql"
  engine_version    = "5.7.19"
  instance_class    = "db.t2.large"
  allocated_storage = 5
  auto_minor_version_upgrade = true
  backup_retention_period =  0


  name     = "demodb"
  username = "user"
  password = "YourPwdShouldBeLongAndSecure!"
  port     = "3306"

  iam_database_authentication_enabled = true
}
```

a policy checking for `backup_retention_period` will result in a finding against the module source but we likely need to reference the module invocation as well, at the moment that's embedded in the path. its useful to have both source references. perhaps a separate caller source reference.

```
{                                                  
    'resource': {
        '__tfmeta': {                              
            'filename': 'modules/db_instance/main.tf',                                                
            'label': 'aws_db_instance',                                                               
            'line_end': 104,
            'line_start': 20,                      
            'path': 'module.db.module.db_instance.aws_db_instance.this[0]',                           
            'type': 'resource',                   
            'src_dir': PosixPath('terraform/aws/tests/rds-with-backup-disabled')
        },
```


Discussion:
Something I noticed with module-related findings is that `resource.__tfmeta.filename` changes depending on whether or not I've run `terraform init|get` before `c7n-left test`:

If `terraform get` has been run, we might see `.terraform/modules/ec2_instance_positive10/main.tf`. Without running the get first, we get the same findings but the filename shows just `main.tf`. I can imagine this being confusing. I can see handling it by performing looser filename checks or relying on `terraform get` running ahead of tests (this seems safer but could be a stumbling block unless we provide a way to auto-run that as needed).

Resolving patch:
diff --git a/tools/c7n_left/c7n_left/core.py b/tools/c7n_left/c7n_left/core.py
--- a/tools/c7n_left/c7n_left/core.py
+++ b/tools/c7n_left/c7n_left/core.py
@@ -278,9 +278,9 @@ def run(self, event, ctx):
         resources = event["resources"]
         resources = self.manager.augment(resources, event)
         resources = self.manager.filter_resources(resources, event)
-        return self.as_results(resources)
+        return self.as_results(resources, event)
 
-    def as_results(self, resources):
+    def as_results(self, resources, event):
         return ResultSet([PolicyResourceResult(r, self.policy) for r in resources])
 
 
diff --git a/tools/c7n_left/c7n_left/providers/terraform/provider.py b/tools/c7n_left/c7n_left/providers/terraform/provider.py
--- a/tools/c7n_left/c7n_left/providers/terraform/provider.py
+++ b/tools/c7n_left/c7n_left/providers/terraform/provider.py
@@ -17,6 +17,8 @@
     IACSourceProvider,
     IACSourceMode,
     log,
+    ResultSet,
+    PolicyResourceResult,
 )
 from .graph import TerraformGraph
 from .filters import Taggable
@@ -153,3 +155,31 @@ def write_file_content(content):
 @execution.register("terraform-source")
 class TerraformSource(IACSourceMode):
     schema = type_schema("terraform-source")
+
+    def as_results(self, resources, event):
+        # for any module based results, we hoist back to the top level, with references
+        for idx, r in enumerate(list(resources)):
+            if not r['__tfmeta']['path'].startswith('module.'):
+                continue
+            resources[idx] = self.resolve_module_ref(r, event['graph'])
+
+        return ResultSet([PolicyResourceResult(r, self.policy) for r in resources])
+
+    def resolve_module_ref(self, mod_resource, graph):
+        mod_map = {}
+        for _, modules in graph.get_resources_by_type('module'):
+            for m in modules:
+                mod_map[m['__tfmeta']['path']] = m
+
+        call_stack = extract_mod_stack(mod_resource['__tfmeta']['path'])
+        ancestor = mod_map[call_stack[0]]
+        ancestor['__tfmeta'].setdefault('call_stack', {})[id(mod_resource)] = call_stack
+        return ancestor
+
+
+def extract_mod_stack(mr_path):
+    call_stack = []
+    parts = mr_path.split('.')
+    for step in range(2, len(parts) + 1, 2):
+        call_stack.append(".".join(parts[:step]))
+    return call_stack


Test patch:
diff --git a/tools/c7n_left/tests/test_left.py b/tools/c7n_left/tests/test_left.py
--- a/tools/c7n_left/tests/test_left.py
+++ b/tools/c7n_left/tests/test_left.py
@@ -3,6 +3,7 @@
 #
 import json
 import os
+import subprocess
 from unittest.mock import ANY
 
 import pytest
@@ -18,6 +19,7 @@
     from c7n_left.providers.terraform.provider import (
         TerraformProvider,
         TerraformResourceManager,
+        extract_mod_stack,
     )
     from c7n_left.providers.terraform.graph import Resolver
 
@@ -115,6 +117,55 @@ def test_load_policy_dir(tmp_path):
     assert len(policies) == 1
 
 
+def test_extract_mod_stack():
+    stack = extract_mod_stack("module.db.module.db_instance.aws_db_instance.this[0]")
+    assert stack == [
+        "module.db",
+        "module.db.module.db_instance",
+        "module.db.module.db_instance.aws_db_instance.this[0]",
+    ]
+
+
+@pytest.mark.skipif(
+    os.environ.get('GITHUB_ACTIONS') is None,
+    reason="runs in github actions as it requires network access for tf init",
+)
+def test_mod_reference(tmp_path):
+    (tmp_path / "main.tf").write_text(
+        """
+module "db" {
+  source  = "terraform-aws-modules/rds/aws"
+  version = "~> 3.0"
+
+  identifier = "demodb"
+
+  engine            = "mysql"
+  engine_version    = "5.7.19"
+  instance_class    = "db.t2.large"
+  allocated_storage = 5
+  auto_minor_version_upgrade = true
+  backup_retention_period =  0
+
+  name     = "demodb"
+  username = "user"
+  port     = "3306"
+}
+        """
+    )
+    subprocess.check_call(args="terraform init", shell=True, cwd=tmp_path)
+    results = run_policy(
+        {
+            "name": "check-backup",
+            "resource": "terraform.aws_db_instance",
+            "filters": [{"backup_retention_period": 0}],
+        },
+        tmp_path,
+        tmp_path,
+    )
+    assert len(results) == 1
+    assert results[0].resource['__tfmeta']['filename'] == 'main.tf'
+
+
 def test_graph_resolver():
     graph = TerraformProvider().parse(terraform_dir / "vpc_flow_logs")
     resolver = graph.build()