lambda-security-scanner 1.0.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.
- lambda_security_scanner/__init__.py +11 -0
- lambda_security_scanner/checks/__init__.py +0 -0
- lambda_security_scanner/checks/access_control.py +636 -0
- lambda_security_scanner/checks/base.py +104 -0
- lambda_security_scanner/checks/code_security.py +212 -0
- lambda_security_scanner/checks/function_config.py +454 -0
- lambda_security_scanner/checks/logging_monitoring.py +175 -0
- lambda_security_scanner/checks/network_security.py +207 -0
- lambda_security_scanner/cli.py +394 -0
- lambda_security_scanner/compliance.py +203 -0
- lambda_security_scanner/html_reporter.py +214 -0
- lambda_security_scanner/scanner.py +1154 -0
- lambda_security_scanner/templates/report.html +397 -0
- lambda_security_scanner/utils.py +191 -0
- lambda_security_scanner-1.0.0.dist-info/METADATA +497 -0
- lambda_security_scanner-1.0.0.dist-info/RECORD +20 -0
- lambda_security_scanner-1.0.0.dist-info/WHEEL +5 -0
- lambda_security_scanner-1.0.0.dist-info/entry_points.txt +2 -0
- lambda_security_scanner-1.0.0.dist-info/licenses/LICENSE +21 -0
- lambda_security_scanner-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Lambda Security Scanner - Comprehensive AWS Lambda security
|
|
2
|
+
auditing tool with multi-framework compliance mapping."""
|
|
3
|
+
|
|
4
|
+
__version__ = "1.0.0"
|
|
5
|
+
__author__ = "Toc Consulting"
|
|
6
|
+
__email__ = "tarek@tocconsulting.fr"
|
|
7
|
+
|
|
8
|
+
from .scanner import LambdaSecurityScanner
|
|
9
|
+
from .compliance import ComplianceChecker
|
|
10
|
+
|
|
11
|
+
__all__ = ["LambdaSecurityScanner", "ComplianceChecker"]
|
|
File without changes
|
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""Access control security checks (B.1 through B.5).
|
|
2
|
+
|
|
3
|
+
Checks resource-based policies, function URLs, CORS
|
|
4
|
+
configuration, execution role permissions, and shared roles.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import urllib.parse
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
from botocore.exceptions import ClientError
|
|
13
|
+
|
|
14
|
+
from .base import BaseChecker
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("lambda_security_scanner")
|
|
17
|
+
|
|
18
|
+
PRIVILEGE_ESCALATION_ACTIONS = [
|
|
19
|
+
"iam:CreatePolicyVersion",
|
|
20
|
+
"iam:SetDefaultPolicyVersion",
|
|
21
|
+
"iam:AttachRolePolicy",
|
|
22
|
+
"iam:AttachUserPolicy",
|
|
23
|
+
"iam:AttachGroupPolicy",
|
|
24
|
+
"iam:PutRolePolicy",
|
|
25
|
+
"iam:PutUserPolicy",
|
|
26
|
+
"iam:PutGroupPolicy",
|
|
27
|
+
"iam:AddUserToGroup",
|
|
28
|
+
"iam:UpdateAssumeRolePolicy",
|
|
29
|
+
"iam:CreateLoginProfile",
|
|
30
|
+
"iam:UpdateLoginProfile",
|
|
31
|
+
"iam:CreateAccessKey",
|
|
32
|
+
"iam:PassRole",
|
|
33
|
+
"lambda:CreateFunction",
|
|
34
|
+
"lambda:UpdateFunctionCode",
|
|
35
|
+
"lambda:InvokeFunction",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
DANGEROUS_MANAGED_POLICIES = [
|
|
39
|
+
"AdministratorAccess",
|
|
40
|
+
"PowerUserAccess",
|
|
41
|
+
"IAMFullAccess",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AccessControlChecker(BaseChecker):
|
|
46
|
+
"""Checks for access control misconfigurations.
|
|
47
|
+
|
|
48
|
+
Covers B.1 (resource policy), B.2 (function URL auth),
|
|
49
|
+
B.3 (CORS), B.4 (execution role), B.5 (shared role).
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def check_resource_policy(
|
|
53
|
+
self, function_name: str, region: str
|
|
54
|
+
) -> Dict:
|
|
55
|
+
"""B.1: Check if resource-based policy allows public access.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
function_name: Lambda function name or ARN.
|
|
59
|
+
region: AWS region name.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Dict with has_policy, is_public, statement_count,
|
|
63
|
+
and public_statement_count.
|
|
64
|
+
"""
|
|
65
|
+
client = self.get_client("lambda", region)
|
|
66
|
+
try:
|
|
67
|
+
response = client.get_policy(
|
|
68
|
+
FunctionName=function_name
|
|
69
|
+
)
|
|
70
|
+
except ClientError as e:
|
|
71
|
+
error_code = e.response.get("Error", {}).get(
|
|
72
|
+
"Code", "Unknown"
|
|
73
|
+
)
|
|
74
|
+
if error_code == "ResourceNotFoundException":
|
|
75
|
+
return {
|
|
76
|
+
"has_policy": False,
|
|
77
|
+
"is_public": False,
|
|
78
|
+
"statement_count": 0,
|
|
79
|
+
"public_statement_count": 0,
|
|
80
|
+
}
|
|
81
|
+
return self.handle_client_error(
|
|
82
|
+
e,
|
|
83
|
+
{
|
|
84
|
+
"has_policy": False,
|
|
85
|
+
"is_public": False,
|
|
86
|
+
"statement_count": 0,
|
|
87
|
+
"public_statement_count": 0,
|
|
88
|
+
},
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
policy = json.loads(response["Policy"])
|
|
92
|
+
statements = policy.get("Statement", [])
|
|
93
|
+
if isinstance(statements, dict):
|
|
94
|
+
statements = [statements]
|
|
95
|
+
|
|
96
|
+
public_count = 0
|
|
97
|
+
for stmt in statements:
|
|
98
|
+
if stmt.get("Effect") != "Allow":
|
|
99
|
+
continue
|
|
100
|
+
if self._is_public_statement(stmt):
|
|
101
|
+
public_count += 1
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
"has_policy": True,
|
|
105
|
+
"is_public": public_count > 0,
|
|
106
|
+
"statement_count": len(statements),
|
|
107
|
+
"public_statement_count": public_count,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def _is_public_statement(self, stmt: Dict) -> bool:
|
|
111
|
+
"""Check if a policy statement grants public access.
|
|
112
|
+
|
|
113
|
+
Matches AWS Security Hub Lambda.1 semantics: a statement is
|
|
114
|
+
public when its principal includes a wildcard (or NotPrincipal
|
|
115
|
+
is used with Effect: Allow) UNLESS the Condition contains a
|
|
116
|
+
fixed-value source restriction (no wildcards, no policy
|
|
117
|
+
variables).
|
|
118
|
+
|
|
119
|
+
Wildcard principals detected:
|
|
120
|
+
- "Principal": "*"
|
|
121
|
+
- "Principal": {"AWS": "*"} or {"AWS": [..., "*", ...]}
|
|
122
|
+
- "Principal": {"Service": "*"} or list variant
|
|
123
|
+
- "Principal": {"Federated": "*"} or list variant
|
|
124
|
+
- "Principal": {"CanonicalUser": "*"} or list variant
|
|
125
|
+
- "NotPrincipal" present (broad-by-default with Allow)
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
stmt: A single policy statement dict.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
True if the statement is publicly accessible.
|
|
132
|
+
"""
|
|
133
|
+
# NotPrincipal with Effect: Allow is broad-by-default
|
|
134
|
+
if "NotPrincipal" in stmt:
|
|
135
|
+
return True
|
|
136
|
+
|
|
137
|
+
principal = stmt.get("Principal", {})
|
|
138
|
+
condition = stmt.get("Condition") or {}
|
|
139
|
+
|
|
140
|
+
is_wildcard = False
|
|
141
|
+
if principal == "*":
|
|
142
|
+
is_wildcard = True
|
|
143
|
+
elif isinstance(principal, dict):
|
|
144
|
+
for key in (
|
|
145
|
+
"AWS",
|
|
146
|
+
"Service",
|
|
147
|
+
"Federated",
|
|
148
|
+
"CanonicalUser",
|
|
149
|
+
):
|
|
150
|
+
val = principal.get(key)
|
|
151
|
+
if val == "*":
|
|
152
|
+
is_wildcard = True
|
|
153
|
+
break
|
|
154
|
+
if (
|
|
155
|
+
isinstance(val, list)
|
|
156
|
+
and "*" in val
|
|
157
|
+
):
|
|
158
|
+
is_wildcard = True
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
if is_wildcard:
|
|
162
|
+
if not condition:
|
|
163
|
+
return True
|
|
164
|
+
return not self._has_fixed_source_restriction(
|
|
165
|
+
condition
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Confused-deputy: a named service principal (e.g.
|
|
169
|
+
# s3.amazonaws.com, events.amazonaws.com) with no source
|
|
170
|
+
# restriction is flagged by AWS Security Hub Lambda.1 as
|
|
171
|
+
# public — any S3 bucket / EventBridge rule across the
|
|
172
|
+
# internet can invoke the function.
|
|
173
|
+
if (
|
|
174
|
+
isinstance(principal, dict)
|
|
175
|
+
and principal.get("Service")
|
|
176
|
+
):
|
|
177
|
+
if not condition:
|
|
178
|
+
return True
|
|
179
|
+
if not self._has_fixed_source_restriction(
|
|
180
|
+
condition
|
|
181
|
+
):
|
|
182
|
+
return True
|
|
183
|
+
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
@staticmethod
|
|
187
|
+
def _has_fixed_source_restriction(
|
|
188
|
+
condition: Dict,
|
|
189
|
+
) -> bool:
|
|
190
|
+
"""Return True only if a fixed-value source key is set.
|
|
191
|
+
|
|
192
|
+
FSBP Lambda.1 rule: a condition restricts public access only
|
|
193
|
+
when the operator is a fixed-value comparison
|
|
194
|
+
(StringEquals/ArnEquals) and the value contains no wildcards
|
|
195
|
+
(``*``) or policy variables (``${...}``). StringLike/ArnLike
|
|
196
|
+
with literal values also count as fixed. Anything with
|
|
197
|
+
wildcards or policy variables does NOT restrict.
|
|
198
|
+
|
|
199
|
+
Source keys recognized: aws:SourceArn, aws:SourceAccount,
|
|
200
|
+
aws:SourceOwner, aws:PrincipalAccount, aws:PrincipalOrgID.
|
|
201
|
+
"""
|
|
202
|
+
FIXED_OPS = (
|
|
203
|
+
"StringEquals",
|
|
204
|
+
"ArnEquals",
|
|
205
|
+
"StringEqualsIfExists",
|
|
206
|
+
"ArnEqualsIfExists",
|
|
207
|
+
)
|
|
208
|
+
LIKE_OPS = (
|
|
209
|
+
"StringLike",
|
|
210
|
+
"ArnLike",
|
|
211
|
+
"StringLikeIfExists",
|
|
212
|
+
"ArnLikeIfExists",
|
|
213
|
+
)
|
|
214
|
+
SOURCE_KEYS = (
|
|
215
|
+
"aws:sourcearn",
|
|
216
|
+
"aws:sourceaccount",
|
|
217
|
+
"aws:sourceowner",
|
|
218
|
+
"aws:principalaccount",
|
|
219
|
+
"aws:principalorgid",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
for op, block in condition.items():
|
|
223
|
+
if not isinstance(block, dict):
|
|
224
|
+
continue
|
|
225
|
+
for cond_key, cond_val in block.items():
|
|
226
|
+
if cond_key.lower() not in SOURCE_KEYS:
|
|
227
|
+
continue
|
|
228
|
+
values = (
|
|
229
|
+
cond_val
|
|
230
|
+
if isinstance(cond_val, list)
|
|
231
|
+
else [cond_val]
|
|
232
|
+
)
|
|
233
|
+
if op in FIXED_OPS or op in LIKE_OPS:
|
|
234
|
+
if all(
|
|
235
|
+
"*" not in str(v)
|
|
236
|
+
and "${" not in str(v)
|
|
237
|
+
for v in values
|
|
238
|
+
):
|
|
239
|
+
return True
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
def check_function_url(
|
|
243
|
+
self, function_name: str, region: str
|
|
244
|
+
) -> Dict:
|
|
245
|
+
"""B.2: Check if function URL has no authentication.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
function_name: Lambda function name or ARN.
|
|
249
|
+
region: AWS region name.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Dict with has_url, auth_type, is_public,
|
|
253
|
+
function_url, and cors.
|
|
254
|
+
"""
|
|
255
|
+
client = self.get_client("lambda", region)
|
|
256
|
+
try:
|
|
257
|
+
response = client.get_function_url_config(
|
|
258
|
+
FunctionName=function_name
|
|
259
|
+
)
|
|
260
|
+
except ClientError as e:
|
|
261
|
+
error_code = e.response.get("Error", {}).get(
|
|
262
|
+
"Code", "Unknown"
|
|
263
|
+
)
|
|
264
|
+
if error_code == "ResourceNotFoundException":
|
|
265
|
+
return {
|
|
266
|
+
"has_url": False,
|
|
267
|
+
"auth_type": None,
|
|
268
|
+
"is_public": False,
|
|
269
|
+
"function_url": None,
|
|
270
|
+
"cors": {},
|
|
271
|
+
}
|
|
272
|
+
return self.handle_client_error(
|
|
273
|
+
e,
|
|
274
|
+
{
|
|
275
|
+
"has_url": False,
|
|
276
|
+
"auth_type": None,
|
|
277
|
+
"is_public": False,
|
|
278
|
+
"function_url": None,
|
|
279
|
+
"cors": {},
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
auth_type = response.get("AuthType", "NONE")
|
|
284
|
+
return {
|
|
285
|
+
"has_url": True,
|
|
286
|
+
"auth_type": auth_type,
|
|
287
|
+
"is_public": auth_type == "NONE",
|
|
288
|
+
"function_url": response.get("FunctionUrl"),
|
|
289
|
+
"cors": response.get("Cors", {}),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
def check_function_url_cors(
|
|
293
|
+
self, url_result: Dict
|
|
294
|
+
) -> Dict:
|
|
295
|
+
"""B.3: Check if function URL CORS allows all origins.
|
|
296
|
+
|
|
297
|
+
Derived from B.2 result; no API call needed.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
url_result: Result dict from check_function_url.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Dict with has_cors, allow_all_origins,
|
|
304
|
+
allow_origins, and allow_credentials.
|
|
305
|
+
"""
|
|
306
|
+
cors = url_result.get("cors", {})
|
|
307
|
+
if not cors:
|
|
308
|
+
return {
|
|
309
|
+
"has_cors": False,
|
|
310
|
+
"allow_all_origins": False,
|
|
311
|
+
"allow_origins": [],
|
|
312
|
+
"allow_credentials": False,
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
allow_origins = cors.get("AllowOrigins", [])
|
|
316
|
+
allow_credentials = cors.get(
|
|
317
|
+
"AllowCredentials", False
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
"has_cors": True,
|
|
322
|
+
"allow_all_origins": "*" in allow_origins,
|
|
323
|
+
"allow_origins": allow_origins,
|
|
324
|
+
"allow_credentials": allow_credentials,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
def check_execution_role(
|
|
328
|
+
self, role_arn: str, region: str
|
|
329
|
+
) -> Dict:
|
|
330
|
+
"""B.4: Check for overly permissive execution role.
|
|
331
|
+
|
|
332
|
+
Inspects managed and inline policies for admin access,
|
|
333
|
+
wildcard actions, and privilege escalation permissions.
|
|
334
|
+
|
|
335
|
+
Args:
|
|
336
|
+
role_arn: IAM role ARN.
|
|
337
|
+
region: AWS region name.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
Dict with role_name, has_admin_access,
|
|
341
|
+
has_wildcard_actions, has_privilege_escalation,
|
|
342
|
+
dangerous_permissions, and attached_policy_count.
|
|
343
|
+
"""
|
|
344
|
+
role_name = role_arn.split("/")[-1]
|
|
345
|
+
iam_client = self.get_client("iam", region)
|
|
346
|
+
|
|
347
|
+
safe_defaults = {
|
|
348
|
+
"role_name": role_name,
|
|
349
|
+
"has_admin_access": False,
|
|
350
|
+
"has_full_admin": False,
|
|
351
|
+
"has_wildcard_actions": False,
|
|
352
|
+
"has_privilege_escalation": False,
|
|
353
|
+
"dangerous_permissions": [],
|
|
354
|
+
"attached_policy_count": 0,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
result = self._analyze_role(
|
|
359
|
+
iam_client, role_name, safe_defaults
|
|
360
|
+
)
|
|
361
|
+
except ClientError as e:
|
|
362
|
+
return self.handle_client_error(
|
|
363
|
+
e, dict(safe_defaults)
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
def _analyze_role(
|
|
369
|
+
self,
|
|
370
|
+
iam_client,
|
|
371
|
+
role_name: str,
|
|
372
|
+
result: Dict,
|
|
373
|
+
) -> Dict:
|
|
374
|
+
"""Analyze all policies attached to a role.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
iam_client: IAM boto3 client.
|
|
378
|
+
role_name: IAM role name.
|
|
379
|
+
result: Result dict to populate.
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Populated result dict.
|
|
383
|
+
"""
|
|
384
|
+
result = dict(result)
|
|
385
|
+
dangerous_permissions = []
|
|
386
|
+
|
|
387
|
+
# Check managed policies
|
|
388
|
+
managed_count = self._check_managed_policies(
|
|
389
|
+
iam_client, role_name, dangerous_permissions
|
|
390
|
+
)
|
|
391
|
+
result["attached_policy_count"] = managed_count
|
|
392
|
+
|
|
393
|
+
# Check inline policies
|
|
394
|
+
self._check_inline_policies(
|
|
395
|
+
iam_client, role_name, dangerous_permissions
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
result["dangerous_permissions"] = dangerous_permissions
|
|
399
|
+
result["has_admin_access"] = any(
|
|
400
|
+
p in dangerous_permissions
|
|
401
|
+
for p in DANGEROUS_MANAGED_POLICIES
|
|
402
|
+
)
|
|
403
|
+
result["has_wildcard_actions"] = any(
|
|
404
|
+
p.endswith(":*") or p == "*"
|
|
405
|
+
for p in dangerous_permissions
|
|
406
|
+
)
|
|
407
|
+
# has_full_admin distinguishes admin-equivalent access
|
|
408
|
+
# (AdministratorAccess/PowerUserAccess/IAMFullAccess managed
|
|
409
|
+
# policies, or a literal "*" action) from a single-service
|
|
410
|
+
# wildcard such as "s3:*". Only the former is scored CRITICAL.
|
|
411
|
+
result["has_full_admin"] = result["has_admin_access"] or any(
|
|
412
|
+
p in ("*", "*:*") for p in dangerous_permissions
|
|
413
|
+
)
|
|
414
|
+
result["has_privilege_escalation"] = any(
|
|
415
|
+
p.lower() in [a.lower() for a in
|
|
416
|
+
PRIVILEGE_ESCALATION_ACTIONS]
|
|
417
|
+
for p in dangerous_permissions
|
|
418
|
+
if ":" in p
|
|
419
|
+
and p not in DANGEROUS_MANAGED_POLICIES
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
return result
|
|
423
|
+
|
|
424
|
+
def _check_managed_policies(
|
|
425
|
+
self,
|
|
426
|
+
iam_client,
|
|
427
|
+
role_name: str,
|
|
428
|
+
dangerous_permissions: List[str],
|
|
429
|
+
) -> int:
|
|
430
|
+
"""Check managed policies for dangerous permissions.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
iam_client: IAM boto3 client.
|
|
434
|
+
role_name: IAM role name.
|
|
435
|
+
dangerous_permissions: List to append findings.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
Count of attached managed policies.
|
|
439
|
+
"""
|
|
440
|
+
paginator = iam_client.get_paginator(
|
|
441
|
+
"list_attached_role_policies"
|
|
442
|
+
)
|
|
443
|
+
attached_policies = []
|
|
444
|
+
for page in paginator.paginate(RoleName=role_name):
|
|
445
|
+
attached_policies.extend(
|
|
446
|
+
page.get("AttachedPolicies", [])
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
for policy in attached_policies:
|
|
450
|
+
policy_name = policy["PolicyName"]
|
|
451
|
+
if policy_name in DANGEROUS_MANAGED_POLICIES:
|
|
452
|
+
dangerous_permissions.append(policy_name)
|
|
453
|
+
continue
|
|
454
|
+
|
|
455
|
+
# Get policy document for detailed analysis
|
|
456
|
+
policy_arn = policy["PolicyArn"]
|
|
457
|
+
try:
|
|
458
|
+
self._analyze_managed_policy(
|
|
459
|
+
iam_client,
|
|
460
|
+
policy_arn,
|
|
461
|
+
dangerous_permissions,
|
|
462
|
+
)
|
|
463
|
+
except ClientError:
|
|
464
|
+
logger.debug(
|
|
465
|
+
"Could not analyze policy %s",
|
|
466
|
+
policy_arn,
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
return len(attached_policies)
|
|
470
|
+
|
|
471
|
+
def _analyze_managed_policy(
|
|
472
|
+
self,
|
|
473
|
+
iam_client,
|
|
474
|
+
policy_arn: str,
|
|
475
|
+
dangerous_permissions: List[str],
|
|
476
|
+
) -> None:
|
|
477
|
+
"""Analyze a single managed policy document.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
iam_client: IAM boto3 client.
|
|
481
|
+
policy_arn: Policy ARN.
|
|
482
|
+
dangerous_permissions: List to append findings.
|
|
483
|
+
"""
|
|
484
|
+
policy_meta = iam_client.get_policy(
|
|
485
|
+
PolicyArn=policy_arn
|
|
486
|
+
)
|
|
487
|
+
version_id = policy_meta["Policy"][
|
|
488
|
+
"DefaultVersionId"
|
|
489
|
+
]
|
|
490
|
+
version = iam_client.get_policy_version(
|
|
491
|
+
PolicyArn=policy_arn,
|
|
492
|
+
VersionId=version_id,
|
|
493
|
+
)
|
|
494
|
+
doc = version["PolicyVersion"]["Document"]
|
|
495
|
+
if isinstance(doc, str):
|
|
496
|
+
try:
|
|
497
|
+
doc = json.loads(
|
|
498
|
+
urllib.parse.unquote(doc)
|
|
499
|
+
)
|
|
500
|
+
except (json.JSONDecodeError, ValueError):
|
|
501
|
+
logger.warning(
|
|
502
|
+
"Could not parse policy document "
|
|
503
|
+
"for %s",
|
|
504
|
+
policy_arn,
|
|
505
|
+
)
|
|
506
|
+
return
|
|
507
|
+
self._analyze_policy_document(
|
|
508
|
+
doc, dangerous_permissions
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
def _check_inline_policies(
|
|
512
|
+
self,
|
|
513
|
+
iam_client,
|
|
514
|
+
role_name: str,
|
|
515
|
+
dangerous_permissions: List[str],
|
|
516
|
+
) -> None:
|
|
517
|
+
"""Check inline policies for dangerous permissions.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
iam_client: IAM boto3 client.
|
|
521
|
+
role_name: IAM role name.
|
|
522
|
+
dangerous_permissions: List to append findings.
|
|
523
|
+
"""
|
|
524
|
+
paginator = iam_client.get_paginator(
|
|
525
|
+
"list_role_policies"
|
|
526
|
+
)
|
|
527
|
+
policy_names = []
|
|
528
|
+
for page in paginator.paginate(RoleName=role_name):
|
|
529
|
+
policy_names.extend(
|
|
530
|
+
page.get("PolicyNames", [])
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
for policy_name in policy_names:
|
|
534
|
+
try:
|
|
535
|
+
response = iam_client.get_role_policy(
|
|
536
|
+
RoleName=role_name,
|
|
537
|
+
PolicyName=policy_name,
|
|
538
|
+
)
|
|
539
|
+
doc = response["PolicyDocument"]
|
|
540
|
+
if isinstance(doc, str):
|
|
541
|
+
try:
|
|
542
|
+
doc = json.loads(
|
|
543
|
+
urllib.parse.unquote(doc)
|
|
544
|
+
)
|
|
545
|
+
except (json.JSONDecodeError, ValueError):
|
|
546
|
+
logger.warning(
|
|
547
|
+
"Could not parse inline "
|
|
548
|
+
"policy %s",
|
|
549
|
+
policy_name,
|
|
550
|
+
)
|
|
551
|
+
continue
|
|
552
|
+
self._analyze_policy_document(
|
|
553
|
+
doc, dangerous_permissions
|
|
554
|
+
)
|
|
555
|
+
except ClientError:
|
|
556
|
+
logger.debug(
|
|
557
|
+
"Could not analyze inline policy %s",
|
|
558
|
+
policy_name,
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
def _analyze_policy_document(
|
|
562
|
+
self,
|
|
563
|
+
document: Dict,
|
|
564
|
+
dangerous_permissions: List[str],
|
|
565
|
+
) -> None:
|
|
566
|
+
"""Analyze a policy document for dangerous perms.
|
|
567
|
+
|
|
568
|
+
Checks for wildcard actions and privilege escalation
|
|
569
|
+
permissions with wildcard resources.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
document: Parsed IAM policy document.
|
|
573
|
+
dangerous_permissions: List to append findings.
|
|
574
|
+
"""
|
|
575
|
+
statements = document.get("Statement", [])
|
|
576
|
+
if isinstance(statements, dict):
|
|
577
|
+
statements = [statements]
|
|
578
|
+
|
|
579
|
+
for stmt in statements:
|
|
580
|
+
if stmt.get("Effect") != "Allow":
|
|
581
|
+
continue
|
|
582
|
+
|
|
583
|
+
actions = stmt.get("Action", [])
|
|
584
|
+
if isinstance(actions, str):
|
|
585
|
+
actions = [actions]
|
|
586
|
+
|
|
587
|
+
resources = stmt.get("Resource", [])
|
|
588
|
+
if isinstance(resources, str):
|
|
589
|
+
resources = [resources]
|
|
590
|
+
|
|
591
|
+
has_wildcard_resource = "*" in resources
|
|
592
|
+
|
|
593
|
+
for action in actions:
|
|
594
|
+
# Check for wildcard actions
|
|
595
|
+
if action == "*" or action.endswith(":*"):
|
|
596
|
+
if action not in dangerous_permissions:
|
|
597
|
+
dangerous_permissions.append(action)
|
|
598
|
+
|
|
599
|
+
# Check for privilege escalation actions
|
|
600
|
+
# with wildcard resource
|
|
601
|
+
if has_wildcard_resource:
|
|
602
|
+
for priv_action in (
|
|
603
|
+
PRIVILEGE_ESCALATION_ACTIONS
|
|
604
|
+
):
|
|
605
|
+
if (
|
|
606
|
+
action.lower()
|
|
607
|
+
== priv_action.lower()
|
|
608
|
+
and action
|
|
609
|
+
not in dangerous_permissions
|
|
610
|
+
):
|
|
611
|
+
dangerous_permissions.append(
|
|
612
|
+
action
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
def check_shared_role(
|
|
616
|
+
self, role_arn: str, all_role_arns: List[str]
|
|
617
|
+
) -> Dict:
|
|
618
|
+
"""B.5: Check if execution role is shared.
|
|
619
|
+
|
|
620
|
+
Compares role_arn against the list of all function
|
|
621
|
+
role ARNs. Flags if more than one function uses the
|
|
622
|
+
same role.
|
|
623
|
+
|
|
624
|
+
Args:
|
|
625
|
+
role_arn: IAM role ARN to check.
|
|
626
|
+
all_role_arns: List of all function role ARNs.
|
|
627
|
+
|
|
628
|
+
Returns:
|
|
629
|
+
Dict with is_shared, shared_count, and role_arn.
|
|
630
|
+
"""
|
|
631
|
+
count = all_role_arns.count(role_arn)
|
|
632
|
+
return {
|
|
633
|
+
"is_shared": count > 1,
|
|
634
|
+
"shared_count": count,
|
|
635
|
+
"role_arn": role_arn,
|
|
636
|
+
}
|