modelmoat 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
modelmoat/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """modelmoat: static analysis for AI infrastructure security in Terraform."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,15 @@
1
+ """Check registry."""
2
+
3
+ from .datastores import VectorDataStoreCheck
4
+ from .iam import AIServiceIAMCheck
5
+ from .network import AIVPCEndpointCheck
6
+ from .s3 import ModelArtifactBucketCheck
7
+ from .sagemaker import SageMakerNetworkCheck
8
+
9
+ ALL_CHECKS = [
10
+ SageMakerNetworkCheck(),
11
+ AIServiceIAMCheck(),
12
+ ModelArtifactBucketCheck(),
13
+ AIVPCEndpointCheck(),
14
+ VectorDataStoreCheck(),
15
+ ]
@@ -0,0 +1,270 @@
1
+ """VEC-001: vector and embedding data stores.
2
+
3
+ Lane discipline: general IaC scanners already flag every unencrypted RDS
4
+ instance on the internet. modelmoat only speaks up when the data store is
5
+ plausibly holding AI data, and its messages never claim more than the
6
+ configuration proves:
7
+
8
+ OpenSearch domains always in scope (the managed vector engine)
9
+ RDS instances/clusters in scope when the engine is Postgres-family
10
+ (pgvector capable) or the name/tags are AI-related
11
+ ElastiCache in scope only when the name/tags are AI-related
12
+
13
+ Explicitly disabled settings (enabled = false) are treated exactly like
14
+ missing ones, and values that come from variables are unknown and never
15
+ flagged.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from ..graph import (
21
+ ProjectGraph,
22
+ Resource,
23
+ ai_tokens_in,
24
+ as_list,
25
+ first_block,
26
+ missing_or_false,
27
+ truthy,
28
+ )
29
+ from ..policy import allows_public_principal, parse_policy_document
30
+ from ..scanner import Finding
31
+
32
+
33
+ class VectorDataStoreCheck:
34
+ check_id = "VEC-001"
35
+ check_name = "Vector Data Store Missing Security Controls"
36
+
37
+ def run(self, graph: ProjectGraph) -> list[Finding]:
38
+ findings: list[Finding] = []
39
+ findings.extend(self._opensearch(graph))
40
+ findings.extend(self._rds(graph))
41
+ findings.extend(self._elasticache(graph))
42
+ return findings
43
+
44
+ # ------------------------------------------------------------------ #
45
+ # OpenSearch #
46
+ # ------------------------------------------------------------------ #
47
+ def _opensearch(self, graph: ProjectGraph) -> list[Finding]:
48
+ findings: list[Finding] = []
49
+
50
+ for domain in graph.by_type("aws_opensearch_domain", "aws_elasticsearch_domain"):
51
+ at_rest = first_block(domain.config, "encrypt_at_rest")
52
+ if at_rest is None or missing_or_false(at_rest.get("enabled")):
53
+ state = "explicitly disables" if at_rest is not None else "does not enable"
54
+ findings.append(
55
+ self._finding(
56
+ domain,
57
+ "HIGH",
58
+ f"OpenSearch domain '{domain.name}' {state} encryption at "
59
+ "rest. Indexed documents and vector embeddings sit "
60
+ "unencrypted on disk.",
61
+ "Set encrypt_at_rest { enabled = true }, ideally with a "
62
+ "customer managed KMS key.",
63
+ "https://docs.aws.amazon.com/opensearch-service/latest/"
64
+ "developerguide/encryption-at-rest.html",
65
+ )
66
+ )
67
+
68
+ node_to_node = first_block(domain.config, "node_to_node_encryption")
69
+ if node_to_node is None or missing_or_false(node_to_node.get("enabled")):
70
+ findings.append(
71
+ self._finding(
72
+ domain,
73
+ "MEDIUM",
74
+ f"OpenSearch domain '{domain.name}' lacks node-to-node "
75
+ "encryption, so traffic between cluster nodes travels in "
76
+ "plaintext.",
77
+ "Set node_to_node_encryption { enabled = true }.",
78
+ "https://docs.aws.amazon.com/opensearch-service/latest/"
79
+ "developerguide/ntn.html",
80
+ )
81
+ )
82
+
83
+ vpc_options = first_block(domain.config, "vpc_options")
84
+ if vpc_options is None:
85
+ access_doc = parse_policy_document(domain.config.get("access_policies"))
86
+ if access_doc is not None and allows_public_principal(access_doc):
87
+ findings.append(
88
+ self._finding(
89
+ domain,
90
+ "CRITICAL",
91
+ f"OpenSearch domain '{domain.name}' has no vpc_options and "
92
+ 'its access policy allows Principal "*". The domain '
93
+ "endpoint is reachable from the internet with no IAM "
94
+ "restriction, which is how vector databases end up in "
95
+ "breach write-ups.",
96
+ "Move the domain into a VPC with vpc_options, or at "
97
+ "minimum restrict the access policy to specific "
98
+ "principals and source conditions.",
99
+ "https://docs.aws.amazon.com/opensearch-service/latest/"
100
+ "developerguide/vpc.html",
101
+ )
102
+ )
103
+ else:
104
+ findings.append(
105
+ self._finding(
106
+ domain,
107
+ "HIGH",
108
+ f"OpenSearch domain '{domain.name}' has no vpc_options, so "
109
+ "its endpoint resolves publicly and reachability is "
110
+ "governed only by the access policy and fine-grained "
111
+ "access control.",
112
+ "Add vpc_options with subnet_ids and security_group_ids "
113
+ "so the domain is only reachable from your network.",
114
+ "https://docs.aws.amazon.com/opensearch-service/latest/"
115
+ "developerguide/vpc.html",
116
+ )
117
+ )
118
+ elif not as_list(vpc_options.get("security_group_ids")):
119
+ findings.append(
120
+ self._finding(
121
+ domain,
122
+ "LOW",
123
+ f"OpenSearch domain '{domain.name}' is in a VPC but sets no "
124
+ "security_group_ids, so it silently uses the VPC default "
125
+ "security group.",
126
+ "Set security_group_ids explicitly and restrict ingress on "
127
+ "443 to the application tiers that query the domain.",
128
+ "https://docs.aws.amazon.com/opensearch-service/latest/"
129
+ "developerguide/vpc.html",
130
+ )
131
+ )
132
+
133
+ return findings
134
+
135
+ # ------------------------------------------------------------------ #
136
+ # RDS / Aurora (pgvector) #
137
+ # ------------------------------------------------------------------ #
138
+ def _rds(self, graph: ProjectGraph) -> list[Finding]:
139
+ findings: list[Finding] = []
140
+
141
+ instances = graph.by_type("aws_db_instance", "aws_rds_cluster_instance")
142
+ clusters = graph.by_type("aws_rds_cluster")
143
+
144
+ for db in instances + clusters:
145
+ engine = str(db.config.get("engine", "")).lower()
146
+ postgres = "postgres" in engine
147
+ names = " ".join(
148
+ str(db.config.get(key, ""))
149
+ for key in ("identifier", "cluster_identifier", "db_name", "name")
150
+ )
151
+ tags = db.config.get("tags") or {}
152
+ tag_values = [str(v) for v in tags.values()] if isinstance(tags, dict) else []
153
+ relevant = postgres or ai_tokens_in(db.name, names, *tag_values)
154
+ if not relevant:
155
+ continue
156
+
157
+ data_note = (
158
+ "a pgvector-capable Postgres database"
159
+ if postgres
160
+ else "a database whose name or tags look AI related"
161
+ )
162
+
163
+ if truthy(db.config.get("publicly_accessible")):
164
+ findings.append(
165
+ self._finding(
166
+ db,
167
+ "CRITICAL",
168
+ f"'{db.name}' sets publicly_accessible = true on {data_note}. "
169
+ "The instance gets a public IP and is reachable from the "
170
+ "internet, guarded only by security groups and database "
171
+ "credentials.",
172
+ "Set publicly_accessible = false and place the database in "
173
+ "private subnets. Reach it through VPC-connected "
174
+ "applications or a bastion.",
175
+ "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/"
176
+ "USER_VPC.html",
177
+ )
178
+ )
179
+
180
+ # storage_encrypted lives on aws_db_instance and aws_rds_cluster,
181
+ # not on cluster instances, which inherit it.
182
+ if db.type != "aws_rds_cluster_instance" and missing_or_false(
183
+ db.config.get("storage_encrypted")
184
+ ):
185
+ findings.append(
186
+ self._finding(
187
+ db,
188
+ "HIGH",
189
+ f"'{db.name}' does not enable storage_encrypted on "
190
+ f"{data_note}. Embeddings and their source text would sit "
191
+ "unencrypted at rest, and encryption cannot be enabled "
192
+ "in place later.",
193
+ "Set storage_encrypted = true, optionally with kms_key_id "
194
+ "for a customer managed key.",
195
+ "https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/"
196
+ "Overview.Encryption.html",
197
+ )
198
+ )
199
+
200
+ return findings
201
+
202
+ # ------------------------------------------------------------------ #
203
+ # ElastiCache (Redis as a vector or embedding cache) #
204
+ # ------------------------------------------------------------------ #
205
+ def _elasticache(self, graph: ProjectGraph) -> list[Finding]:
206
+ findings: list[Finding] = []
207
+
208
+ caches = graph.by_type(
209
+ "aws_elasticache_replication_group", "aws_elasticache_cluster"
210
+ )
211
+ for cache in caches:
212
+ names = " ".join(
213
+ str(cache.config.get(key, ""))
214
+ for key in ("replication_group_id", "cluster_id", "description")
215
+ )
216
+ tags = cache.config.get("tags") or {}
217
+ tag_values = [str(v) for v in tags.values()] if isinstance(tags, dict) else []
218
+ if not ai_tokens_in(cache.name, names, *tag_values):
219
+ continue
220
+
221
+ if missing_or_false(cache.config.get("transit_encryption_enabled")):
222
+ findings.append(
223
+ self._finding(
224
+ cache,
225
+ "HIGH",
226
+ f"ElastiCache '{cache.name}' looks AI related but does not "
227
+ "enable transit encryption, so embeddings and cached "
228
+ "completions cross the network in plaintext.",
229
+ "Set transit_encryption_enabled = true and require an "
230
+ "auth_token or RBAC users.",
231
+ "https://docs.aws.amazon.com/AmazonElastiCache/latest/"
232
+ "red-ug/in-transit-encryption.html",
233
+ )
234
+ )
235
+
236
+ if missing_or_false(cache.config.get("at_rest_encryption_enabled")):
237
+ findings.append(
238
+ self._finding(
239
+ cache,
240
+ "MEDIUM",
241
+ f"ElastiCache '{cache.name}' looks AI related but does not "
242
+ "enable at-rest encryption.",
243
+ "Set at_rest_encryption_enabled = true.",
244
+ "https://docs.aws.amazon.com/AmazonElastiCache/latest/"
245
+ "red-ug/at-rest-encryption.html",
246
+ )
247
+ )
248
+
249
+ return findings
250
+
251
+ def _finding(
252
+ self,
253
+ resource: Resource,
254
+ severity: str,
255
+ message: str,
256
+ remediation: str,
257
+ docs_url: str,
258
+ ) -> Finding:
259
+ return Finding(
260
+ check_id=self.check_id,
261
+ check_name=self.check_name,
262
+ severity=severity,
263
+ resource_type=resource.type,
264
+ resource_name=resource.name,
265
+ file_path=str(resource.file),
266
+ line=resource.line,
267
+ message=message,
268
+ remediation=remediation,
269
+ docs_url=docs_url,
270
+ )
@@ -0,0 +1,178 @@
1
+ """IAM-001: blanket AI service permissions.
2
+
3
+ Flags bedrock:* / sagemaker:* style actions granted on Resource "*", wherever
4
+ the policy lives: inline aws_iam_role_policy, standalone aws_iam_policy,
5
+ data.aws_iam_policy_document, or an attached AWS managed FullAccess policy.
6
+ Roles are resolved across files, and findings name the Lambda functions that
7
+ use the role so the blast radius is obvious.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from ..graph import ProjectGraph, Resource, extract_ref
13
+ from ..policy import (
14
+ parse_policy_document,
15
+ raw_wildcard_scan,
16
+ risky_managed_policy,
17
+ statement_block_grants,
18
+ wildcard_ai_grants,
19
+ )
20
+ from ..scanner import Finding
21
+
22
+ _DOCS = "https://docs.aws.amazon.com/bedrock/latest/userguide/security-iam.html"
23
+ _REMEDIATION = (
24
+ "Scope the policy to the specific actions and ARNs the workload needs, for "
25
+ "example bedrock:InvokeModel on the exact foundation model ARN. Wildcard "
26
+ "actions on Resource \"*\" let the principal invoke, create, or delete any "
27
+ "AI resource in the account."
28
+ )
29
+
30
+
31
+ class AIServiceIAMCheck:
32
+ check_id = "IAM-001"
33
+ check_name = "Blanket AI Service IAM Permissions"
34
+
35
+ def run(self, graph: ProjectGraph) -> list[Finding]:
36
+ findings: list[Finding] = []
37
+
38
+ roles = graph.by_type("aws_iam_role")
39
+ role_labels = {r.name for r in roles}
40
+ role_by_declared_name = {
41
+ str(r.config.get("name")): r.name
42
+ for r in roles
43
+ if isinstance(r.config.get("name"), str)
44
+ }
45
+
46
+ lambdas_by_role = self._lambdas_by_role(graph, role_by_declared_name)
47
+ data_docs = {d.name: d for d in graph.data_by_type("aws_iam_policy_document")}
48
+
49
+ def resolve_role(value) -> str | None:
50
+ label = extract_ref(value, "aws_iam_role")
51
+ if label:
52
+ return label
53
+ if isinstance(value, str):
54
+ if value in role_labels:
55
+ return value
56
+ if value in role_by_declared_name:
57
+ return role_by_declared_name[value]
58
+ return None
59
+
60
+ def usage(label: str | None) -> str:
61
+ names = lambdas_by_role.get(label or "", [])
62
+ if names:
63
+ return "used by Lambda function(s) " + ", ".join(sorted(names))
64
+ return "not attached to any Lambda function in the scanned files"
65
+
66
+ # Inline role policies.
67
+ for policy in graph.by_type("aws_iam_role_policy"):
68
+ matched = self._grants(policy.config.get("policy"), data_docs)
69
+ if not matched:
70
+ continue
71
+ label = resolve_role(policy.config.get("role"))
72
+ findings.append(
73
+ self._finding(
74
+ policy,
75
+ f"Inline policy '{policy.name}' on role "
76
+ f"'{label or policy.config.get('role')}' grants "
77
+ f"{', '.join(matched)} on Resource \"*\". The role is "
78
+ f"{usage(label)}.",
79
+ )
80
+ )
81
+
82
+ # Standalone customer managed policies.
83
+ attachments = graph.by_type(
84
+ "aws_iam_role_policy_attachment", "aws_iam_policy_attachment"
85
+ )
86
+ attached_roles_by_policy: dict[str, set[str]] = {}
87
+ for attachment in attachments:
88
+ policy_label = extract_ref(attachment.config.get("policy_arn"), "aws_iam_policy")
89
+ if not policy_label:
90
+ continue
91
+ role_refs = [attachment.config.get("role")] + list(
92
+ attachment.config.get("roles") or []
93
+ )
94
+ for ref in role_refs:
95
+ label = resolve_role(ref)
96
+ if label:
97
+ attached_roles_by_policy.setdefault(policy_label, set()).add(label)
98
+
99
+ for policy in graph.by_type("aws_iam_policy"):
100
+ matched = self._grants(policy.config.get("policy"), data_docs)
101
+ if not matched:
102
+ continue
103
+ attached = sorted(attached_roles_by_policy.get(policy.name, set()))
104
+ lam_names: list[str] = []
105
+ for label in attached:
106
+ lam_names.extend(lambdas_by_role.get(label, []))
107
+ if attached:
108
+ context = f"attached to role(s) {', '.join(attached)}"
109
+ if lam_names:
110
+ context += f", used by Lambda function(s) {', '.join(sorted(set(lam_names)))}"
111
+ else:
112
+ context = "not attached to any role in the scanned files"
113
+ findings.append(
114
+ self._finding(
115
+ policy,
116
+ f"Managed policy '{policy.name}' grants {', '.join(matched)} on "
117
+ f"Resource \"*\" and is {context}.",
118
+ )
119
+ )
120
+
121
+ # AWS managed FullAccess attachments. Matching is case-insensitive on
122
+ # both sides so mixed-case ARNs cannot slip past.
123
+ for attachment in attachments:
124
+ arn = attachment.config.get("policy_arn")
125
+ matched_name = risky_managed_policy(arn)
126
+ if not matched_name:
127
+ continue
128
+ label = resolve_role(attachment.config.get("role"))
129
+ findings.append(
130
+ self._finding(
131
+ attachment,
132
+ f"Role '{label or attachment.config.get('role')}' attaches AWS "
133
+ f"managed policy '{arn}', which grants blanket AI service access. "
134
+ f"The role is {usage(label)}.",
135
+ )
136
+ )
137
+
138
+ return findings
139
+
140
+ def _lambdas_by_role(
141
+ self, graph: ProjectGraph, role_by_declared_name: dict[str, str]
142
+ ) -> dict[str, list[str]]:
143
+ mapping: dict[str, list[str]] = {}
144
+ for function in graph.by_type("aws_lambda_function"):
145
+ role_value = function.config.get("role")
146
+ label = extract_ref(role_value, "aws_iam_role")
147
+ if not label and isinstance(role_value, str):
148
+ label = role_by_declared_name.get(role_value)
149
+ if label:
150
+ mapping.setdefault(label, []).append(function.name)
151
+ return mapping
152
+
153
+ def _grants(self, policy_value, data_docs: dict[str, Resource]) -> list[str]:
154
+ doc = parse_policy_document(policy_value)
155
+ if doc is not None:
156
+ return wildcard_ai_grants(doc)
157
+
158
+ data_label = extract_ref(policy_value, "data.aws_iam_policy_document") or extract_ref(
159
+ policy_value, "aws_iam_policy_document"
160
+ )
161
+ if data_label and data_label in data_docs:
162
+ return statement_block_grants(data_docs[data_label].config)
163
+
164
+ return raw_wildcard_scan(policy_value)
165
+
166
+ def _finding(self, resource: Resource, message: str) -> Finding:
167
+ return Finding(
168
+ check_id=self.check_id,
169
+ check_name=self.check_name,
170
+ severity="HIGH",
171
+ resource_type=resource.type,
172
+ resource_name=resource.name,
173
+ file_path=str(resource.file),
174
+ line=resource.line,
175
+ message=message,
176
+ remediation=_REMEDIATION,
177
+ docs_url=_DOCS,
178
+ )
@@ -0,0 +1,148 @@
1
+ """VPC-001: AI service traffic without PrivateLink.
2
+
3
+ VPC endpoints only matter for traffic that originates inside a VPC, so the
4
+ check distinguishes two situations instead of shouting at both:
5
+
6
+ MEDIUM a VPC-attached Lambda shows signals of calling Bedrock or SageMaker
7
+ and no matching interface endpoint exists anywhere in the project.
8
+ Depending on routing, those calls either fail or leave through a
9
+ NAT gateway to public AWS endpoints.
10
+ LOW a Lambda with AI signals runs outside any VPC. Its traffic uses
11
+ public AWS endpoints, which are TLS plus IAM authenticated. That is
12
+ acceptable for many workloads and the finding says so.
13
+
14
+ Endpoint matching is on the service fragment (".bedrock-runtime",
15
+ ".sagemaker.runtime"), so a service_name built from a region variable still
16
+ matches and does not produce a false positive.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from ..graph import ProjectGraph, Resource, blocks, extract_ref
22
+ from ..scanner import Finding
23
+
24
+ _DOCS = "https://docs.aws.amazon.com/bedrock/latest/userguide/vpc-interface-endpoints.html"
25
+
26
+ _SERVICES = {
27
+ "bedrock": ".bedrock-runtime",
28
+ "sagemaker": ".sagemaker.runtime",
29
+ }
30
+
31
+
32
+ class AIVPCEndpointCheck:
33
+ check_id = "VPC-001"
34
+ check_name = "AI Service Traffic Without PrivateLink"
35
+
36
+ def run(self, graph: ProjectGraph) -> list[Finding]:
37
+ findings: list[Finding] = []
38
+
39
+ endpoint_services = [
40
+ str(endpoint.config.get("service_name", "")).lower()
41
+ for endpoint in graph.by_type("aws_vpc_endpoint")
42
+ ]
43
+
44
+ def endpoint_exists(fragment: str) -> bool:
45
+ return any(fragment in service for service in endpoint_services)
46
+
47
+ role_signals = self._role_signals(graph)
48
+
49
+ for function in graph.by_type("aws_lambda_function"):
50
+ in_vpc = bool(blocks(function.config, "vpc_config"))
51
+ signals = self._function_signals(function, role_signals)
52
+
53
+ for service in sorted(signals):
54
+ fragment = _SERVICES[service]
55
+ if in_vpc and not endpoint_exists(fragment):
56
+ findings.append(
57
+ Finding(
58
+ check_id=self.check_id,
59
+ check_name=self.check_name,
60
+ severity="MEDIUM",
61
+ resource_type=function.type,
62
+ resource_name=function.name,
63
+ file_path=str(function.file),
64
+ line=function.line,
65
+ message=(
66
+ f"Lambda '{function.name}' runs inside a VPC and shows "
67
+ f"signals of calling {service}, but no interface VPC "
68
+ f"endpoint matching '{fragment}' exists in the scanned "
69
+ "files. Depending on routing, calls either fail or exit "
70
+ "through NAT to public AWS endpoints."
71
+ ),
72
+ remediation=(
73
+ "Add an aws_vpc_endpoint with vpc_endpoint_type "
74
+ '"Interface" and service_name '
75
+ f'"com.amazonaws.<region>{fragment}" in the same VPC, '
76
+ "and allow the Lambda security group to reach it on 443."
77
+ ),
78
+ docs_url=_DOCS,
79
+ )
80
+ )
81
+ elif not in_vpc:
82
+ findings.append(
83
+ Finding(
84
+ check_id=self.check_id,
85
+ check_name=self.check_name,
86
+ severity="LOW",
87
+ resource_type=function.type,
88
+ resource_name=function.name,
89
+ file_path=str(function.file),
90
+ line=function.line,
91
+ message=(
92
+ f"Lambda '{function.name}' shows signals of calling "
93
+ f"{service} and is not attached to a VPC, so traffic "
94
+ "uses public AWS endpoints. Those are TLS and IAM "
95
+ "authenticated, which many workloads accept. For "
96
+ "sensitive prompts or regulated data, keep the traffic "
97
+ "on your private network."
98
+ ),
99
+ remediation=(
100
+ "If this workload handles sensitive data, attach the "
101
+ "Lambda to a VPC and add an interface endpoint for "
102
+ f'"com.amazonaws.<region>{fragment}".'
103
+ ),
104
+ docs_url=_DOCS,
105
+ )
106
+ )
107
+
108
+ return findings
109
+
110
+ def _function_signals(
111
+ self, function: Resource, role_signals: dict[str, set[str]]
112
+ ) -> set[str]:
113
+ signals: set[str] = set()
114
+
115
+ for environment in blocks(function.config, "environment"):
116
+ variables = environment.get("variables")
117
+ if isinstance(variables, dict):
118
+ for key, value in variables.items():
119
+ text = f"{key} {value}".lower()
120
+ for service in _SERVICES:
121
+ if service in text:
122
+ signals.add(service)
123
+
124
+ role_label = extract_ref(function.config.get("role"), "aws_iam_role")
125
+ if role_label:
126
+ signals |= role_signals.get(role_label, set())
127
+
128
+ return signals
129
+
130
+ def _role_signals(self, graph: ProjectGraph) -> dict[str, set[str]]:
131
+ """Which AI services each role's policies mention, resolved cross-file."""
132
+ signals: dict[str, set[str]] = {}
133
+
134
+ def note(role_value, text: str) -> None:
135
+ label = extract_ref(role_value, "aws_iam_role")
136
+ if not label:
137
+ return
138
+ lowered = text.lower()
139
+ for service in _SERVICES:
140
+ if service in lowered:
141
+ signals.setdefault(label, set()).add(service)
142
+
143
+ for policy in graph.by_type("aws_iam_role_policy"):
144
+ note(policy.config.get("role"), str(policy.config.get("policy", "")))
145
+ for attachment in graph.by_type("aws_iam_role_policy_attachment"):
146
+ note(attachment.config.get("role"), str(attachment.config.get("policy_arn", "")))
147
+
148
+ return signals