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 +3 -0
- modelmoat/checks/__init__.py +15 -0
- modelmoat/checks/datastores.py +270 -0
- modelmoat/checks/iam.py +178 -0
- modelmoat/checks/network.py +148 -0
- modelmoat/checks/s3.py +182 -0
- modelmoat/checks/sagemaker.py +58 -0
- modelmoat/cli.py +159 -0
- modelmoat/graph.py +235 -0
- modelmoat/policy.py +179 -0
- modelmoat/scanner.py +121 -0
- modelmoat-0.1.0.dist-info/METADATA +170 -0
- modelmoat-0.1.0.dist-info/RECORD +16 -0
- modelmoat-0.1.0.dist-info/WHEEL +4 -0
- modelmoat-0.1.0.dist-info/entry_points.txt +2 -0
- modelmoat-0.1.0.dist-info/licenses/LICENSE +202 -0
modelmoat/__init__.py
ADDED
|
@@ -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
|
+
)
|
modelmoat/checks/iam.py
ADDED
|
@@ -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
|