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.
@@ -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
+ }