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,1154 @@
1
+ #!/usr/bin/env python3
2
+ """Lambda Security Scanner - Main orchestrator with multi-threading
3
+ and compliance mapping."""
4
+
5
+ import csv
6
+ import json
7
+ import os
8
+ import threading
9
+ from concurrent.futures import ThreadPoolExecutor, as_completed
10
+ from datetime import datetime
11
+ from typing import Dict, List, Optional, Any
12
+
13
+ import boto3
14
+ from botocore.exceptions import NoCredentialsError, ClientError
15
+ from rich.console import Console
16
+ from rich.table import Table
17
+ from rich.progress import Progress, SpinnerColumn, TextColumn
18
+
19
+ from .compliance import ComplianceChecker
20
+ from .html_reporter import HTMLReporter
21
+ from .utils import (
22
+ setup_logging,
23
+ calculate_security_score,
24
+ get_severity_color,
25
+ )
26
+
27
+ from .checks.function_config import FunctionConfigChecker
28
+ from .checks.access_control import AccessControlChecker
29
+ from .checks.network_security import NetworkSecurityChecker
30
+ from .checks.logging_monitoring import LoggingMonitoringChecker
31
+ from .checks.code_security import CodeSecurityChecker
32
+
33
+
34
+ class LambdaSecurityScanner:
35
+ """Lambda Security Scanner driving all security checks.
36
+
37
+ Facade pattern: orchestrates scanning across 5 checker modules,
38
+ manages thread pool, progress display, and report generation.
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ region: str = "us-east-1",
44
+ profile: Optional[str] = None,
45
+ output_dir: str = "./output",
46
+ max_workers: int = 5,
47
+ quiet: bool = False,
48
+ ):
49
+ self.region = region
50
+ self.profile = profile
51
+ self.output_dir = output_dir
52
+ self.max_workers = max_workers
53
+ self.quiet = quiet
54
+ self.console = Console(quiet=quiet)
55
+
56
+ os.makedirs(output_dir, exist_ok=True)
57
+ # Preserve any log level already set by caller
58
+ # (e.g. --debug in cli.py sets DEBUG before __init__)
59
+ import logging as _logging
60
+ _existing = _logging.getLogger(
61
+ "lambda_security_scanner"
62
+ ).level
63
+ _level = (
64
+ _existing
65
+ if _existing != _logging.NOTSET
66
+ else _logging.INFO
67
+ )
68
+ self.logger = setup_logging(output_dir, _level)
69
+
70
+ # Thread safety
71
+ self._thread_local = threading.local()
72
+
73
+ # Main thread session
74
+ try:
75
+ self._session = self._create_session()
76
+ self.lambda_client = self._session.client(
77
+ "lambda", region_name=region
78
+ )
79
+ self.account_id = self._get_account_id()
80
+ except NoCredentialsError:
81
+ self.logger.error(
82
+ "No AWS credentials found. "
83
+ "Please configure your credentials."
84
+ )
85
+ raise
86
+
87
+ # 5 checker modules with session factory
88
+ self.config_checker = FunctionConfigChecker(
89
+ self._get_thread_session
90
+ )
91
+ self.access_checker = AccessControlChecker(
92
+ self._get_thread_session
93
+ )
94
+ self.network_checker = NetworkSecurityChecker(
95
+ self._get_thread_session
96
+ )
97
+ self.logging_checker = LoggingMonitoringChecker(
98
+ self._get_thread_session
99
+ )
100
+ self.code_checker = CodeSecurityChecker(
101
+ self._get_thread_session
102
+ )
103
+
104
+ # Compliance & reporting
105
+ self.compliance_checker = ComplianceChecker()
106
+ self.html_reporter = HTMLReporter()
107
+
108
+ def _create_session(self) -> boto3.Session:
109
+ if self.profile:
110
+ return boto3.Session(
111
+ profile_name=self.profile,
112
+ region_name=self.region,
113
+ )
114
+ return boto3.Session(region_name=self.region)
115
+
116
+ def _get_thread_session(self) -> boto3.Session:
117
+ if not hasattr(self._thread_local, "session"):
118
+ self._thread_local.session = (
119
+ self._create_session()
120
+ )
121
+ return self._thread_local.session
122
+
123
+ def _get_account_id(self) -> str:
124
+ try:
125
+ sts = self._session.client("sts")
126
+ return sts.get_caller_identity()["Account"]
127
+ except Exception as e:
128
+ self.logger.debug(
129
+ f"Could not determine account ID: {e}"
130
+ )
131
+ return "unknown"
132
+
133
+ # ============================================================
134
+ # Function Enumeration
135
+ # ============================================================
136
+
137
+ def get_all_functions(self) -> List[Dict[str, Any]]:
138
+ """Retrieve all Lambda functions using pagination."""
139
+ try:
140
+ paginator = self.lambda_client.get_paginator(
141
+ "list_functions"
142
+ )
143
+ functions = []
144
+ for page in paginator.paginate():
145
+ functions.extend(
146
+ page.get("Functions", [])
147
+ )
148
+ self.logger.info(
149
+ f"Found {len(functions)} Lambda functions "
150
+ f"in account {self.account_id}"
151
+ )
152
+ return functions
153
+ except Exception as e:
154
+ self.logger.error(
155
+ f"Error retrieving Lambda functions: {e}"
156
+ )
157
+ return []
158
+
159
+ # ============================================================
160
+ # Per-Function Scanning
161
+ # ============================================================
162
+
163
+ def scan_function(
164
+ self,
165
+ func_config: Dict,
166
+ all_role_arns: List[str],
167
+ progress=None,
168
+ task=None,
169
+ ) -> Dict[str, Any]:
170
+ """Run all 19 checks for a single function."""
171
+ func_name = func_config.get(
172
+ "FunctionName", "unknown"
173
+ )
174
+ func_arn = func_config.get("FunctionArn", "")
175
+ role_arn = func_config.get("Role", "")
176
+ package_type = func_config.get(
177
+ "PackageType", "Zip"
178
+ )
179
+
180
+ try:
181
+ # Build checks dict
182
+ checks = {}
183
+
184
+ # A. Function Configuration (A.1-A.7)
185
+ checks["runtime"] = (
186
+ self.config_checker.check_runtime(
187
+ func_config
188
+ )
189
+ )
190
+ checks["timeout"] = (
191
+ self.config_checker.check_timeout(
192
+ func_config
193
+ )
194
+ )
195
+ checks["environment_secrets"] = (
196
+ self.config_checker.check_environment_secrets(
197
+ func_config
198
+ )
199
+ )
200
+ checks["ephemeral_storage"] = (
201
+ self.config_checker.check_ephemeral_storage(
202
+ func_config
203
+ )
204
+ )
205
+ checks["layers"] = (
206
+ self.config_checker.check_layers(
207
+ func_config, self.account_id
208
+ )
209
+ )
210
+ checks["tracing"] = (
211
+ self.config_checker.check_tracing(
212
+ func_config
213
+ )
214
+ )
215
+ checks["dead_letter_config"] = (
216
+ self.config_checker.check_dead_letter_config(
217
+ func_config
218
+ )
219
+ )
220
+
221
+ # B. Access Control (B.1-B.5)
222
+ checks["resource_policy"] = (
223
+ self.access_checker.check_resource_policy(
224
+ func_name, self.region
225
+ )
226
+ )
227
+ checks["function_url"] = (
228
+ self.access_checker.check_function_url(
229
+ func_name, self.region
230
+ )
231
+ )
232
+ checks["function_url_cors"] = (
233
+ self.access_checker.check_function_url_cors(
234
+ checks["function_url"]
235
+ )
236
+ )
237
+ checks["execution_role"] = (
238
+ self.access_checker.check_execution_role(
239
+ role_arn, self.region
240
+ )
241
+ )
242
+ checks["shared_role"] = (
243
+ self.access_checker.check_shared_role(
244
+ role_arn, all_role_arns
245
+ )
246
+ )
247
+
248
+ # C. Network Security (C.1-C.3)
249
+ checks["vpc_config"] = (
250
+ self.network_checker.check_vpc_config(
251
+ func_config
252
+ )
253
+ )
254
+ checks["multi_az"] = (
255
+ self.network_checker.check_multi_az(
256
+ checks["vpc_config"], self.region
257
+ )
258
+ )
259
+ checks["security_groups"] = (
260
+ self.network_checker.check_security_groups(
261
+ checks["vpc_config"], self.region
262
+ )
263
+ )
264
+
265
+ # D. Logging & Monitoring (D.1-D.2)
266
+ checks["log_group"] = (
267
+ self.logging_checker.check_log_group(
268
+ func_name, self.region, func_config
269
+ )
270
+ )
271
+ checks["reserved_concurrency"] = (
272
+ self.logging_checker.check_reserved_concurrency(
273
+ func_name, self.region
274
+ )
275
+ )
276
+
277
+ # E. Code & Supply Chain (E.1-E.2)
278
+ checks["code_signing"] = (
279
+ self.code_checker.check_code_signing(
280
+ func_name, self.region, package_type
281
+ )
282
+ )
283
+ checks["event_source_mappings"] = (
284
+ self.code_checker.check_event_source_mappings(
285
+ func_name, self.region
286
+ )
287
+ )
288
+
289
+ # Analyze issues
290
+ issues = self._analyze_issues(checks)
291
+
292
+ # Calculate score
293
+ security_score = calculate_security_score(
294
+ checks
295
+ )
296
+
297
+ # Compliance
298
+ compliance_status = (
299
+ self.compliance_checker
300
+ .check_function_compliance(checks)
301
+ )
302
+
303
+ # Determine public status
304
+ is_public = (
305
+ checks.get("resource_policy", {}).get(
306
+ "is_public", False
307
+ )
308
+ or checks.get("function_url", {}).get(
309
+ "is_public", False
310
+ )
311
+ )
312
+
313
+ result = {
314
+ "function_name": func_name,
315
+ "function_arn": func_arn,
316
+ "region": self.region,
317
+ "account_id": self.account_id,
318
+ **checks,
319
+ "is_public": is_public,
320
+ "issues": issues,
321
+ "issue_count": len(issues),
322
+ "has_critical_issues": any(
323
+ i["severity"] == "CRITICAL"
324
+ for i in issues
325
+ ),
326
+ "has_high_issues": any(
327
+ i["severity"] == "HIGH"
328
+ for i in issues
329
+ ),
330
+ "security_score": security_score,
331
+ "compliance_status": compliance_status,
332
+ }
333
+
334
+ if progress and task:
335
+ progress.advance(task)
336
+
337
+ return result
338
+
339
+ except Exception as e:
340
+ self.logger.error(
341
+ f"Error scanning function {func_name}: {e}"
342
+ )
343
+ if progress and task:
344
+ progress.advance(task)
345
+ return self._error_result(
346
+ func_name, str(e)
347
+ )
348
+
349
+ def _analyze_issues(
350
+ self, checks: Dict
351
+ ) -> List[Dict[str, Any]]:
352
+ """Generate issue list from checks dict."""
353
+ issues = []
354
+
355
+ def add(severity, issue_type, desc, rec):
356
+ issues.append({
357
+ "severity": severity,
358
+ "issue_type": issue_type,
359
+ "description": desc,
360
+ "recommendation": rec,
361
+ })
362
+
363
+ # A.1 Runtime
364
+ rt = checks.get("runtime", {})
365
+ status = rt.get("status", "supported")
366
+ if status == "blocked":
367
+ add(
368
+ "CRITICAL", "blocked_runtime",
369
+ f"Runtime {rt.get('runtime')} is blocked",
370
+ "Migrate to a supported runtime "
371
+ "immediately",
372
+ )
373
+ elif status == "deprecated":
374
+ add(
375
+ "HIGH", "deprecated_runtime",
376
+ f"Runtime {rt.get('runtime')} is "
377
+ "deprecated",
378
+ "Migrate to a supported runtime",
379
+ )
380
+ elif status == "near_eol":
381
+ add(
382
+ "LOW", "near_eol_runtime",
383
+ f"Runtime {rt.get('runtime')} approaching "
384
+ f"EOL ({rt.get('eol_date')})",
385
+ "Plan migration to a newer runtime",
386
+ )
387
+
388
+ # A.2 Timeout
389
+ if checks.get("timeout", {}).get(
390
+ "is_max_timeout"
391
+ ):
392
+ add(
393
+ "LOW", "max_timeout",
394
+ "Function has maximum timeout (900s)",
395
+ "Review and set an appropriate timeout",
396
+ )
397
+
398
+ # A.3 Secrets
399
+ env = checks.get("environment_secrets", {})
400
+ if env.get("has_secrets"):
401
+ if not env.get("has_kms_key"):
402
+ add(
403
+ "CRITICAL", "env_secrets_no_kms",
404
+ "Secrets found in environment "
405
+ "variables without KMS encryption",
406
+ "Move secrets to Secrets Manager or "
407
+ "SSM Parameter Store",
408
+ )
409
+ else:
410
+ add(
411
+ "HIGH", "env_secrets_with_kms",
412
+ "Secrets found in environment "
413
+ "variables (KMS encrypted)",
414
+ "Move secrets to Secrets Manager or "
415
+ "SSM Parameter Store",
416
+ )
417
+
418
+ # A.4 Ephemeral storage
419
+ if checks.get("ephemeral_storage", {}).get(
420
+ "is_large"
421
+ ):
422
+ add(
423
+ "LOW", "large_ephemeral_storage",
424
+ "Ephemeral storage exceeds 512 MB",
425
+ "Ensure sensitive data in /tmp is "
426
+ "cleaned",
427
+ )
428
+
429
+ # A.5 External layers
430
+ if checks.get("layers", {}).get(
431
+ "has_external_layers"
432
+ ):
433
+ add(
434
+ "MEDIUM", "external_layers",
435
+ "Function uses external Lambda layers",
436
+ "Verify external layers are from "
437
+ "trusted sources",
438
+ )
439
+
440
+ # A.6 Tracing (observability hygiene, not a direct security gap)
441
+ if not checks.get("tracing", {}).get("enabled"):
442
+ add(
443
+ "LOW", "tracing_disabled",
444
+ "X-Ray tracing is disabled",
445
+ "Enable Active tracing for distributed "
446
+ "tracing",
447
+ )
448
+
449
+ # A.7 DLQ (resilience for async invokes, not a direct security gap)
450
+ if not checks.get("dead_letter_config", {}).get(
451
+ "configured"
452
+ ):
453
+ add(
454
+ "LOW", "no_dlq",
455
+ "No dead letter queue configured",
456
+ "Configure an SQS or SNS dead letter queue "
457
+ "for async invocations",
458
+ )
459
+
460
+ # B.1 Resource policy
461
+ if checks.get("resource_policy", {}).get(
462
+ "is_public"
463
+ ):
464
+ add(
465
+ "CRITICAL", "public_resource_policy",
466
+ "Resource policy allows public access",
467
+ "Restrict the resource policy Principal",
468
+ )
469
+
470
+ # B.2 Function URL
471
+ if checks.get("function_url", {}).get(
472
+ "is_public"
473
+ ):
474
+ add(
475
+ "CRITICAL", "public_function_url",
476
+ "Function URL has no authentication "
477
+ "(AuthType: NONE)",
478
+ "Set AuthType to AWS_IAM or remove "
479
+ "the URL",
480
+ )
481
+
482
+ # B.3 CORS
483
+ if checks.get("function_url_cors", {}).get(
484
+ "allow_all_origins"
485
+ ):
486
+ add(
487
+ "HIGH", "cors_wildcard",
488
+ "Function URL CORS allows all origins",
489
+ "Restrict AllowOrigins to specific "
490
+ "domains",
491
+ )
492
+
493
+ # B.4 Execution role
494
+ role = checks.get("execution_role", {})
495
+ if role.get("has_full_admin"):
496
+ add(
497
+ "CRITICAL", "admin_execution_role",
498
+ "Execution role has admin-equivalent access "
499
+ "(AdministratorAccess/PowerUserAccess or '*')",
500
+ "Apply least privilege to the role",
501
+ )
502
+ elif role.get("has_wildcard_actions"):
503
+ add(
504
+ "HIGH", "service_wildcard_execution_role",
505
+ "Execution role grants service-level wildcard "
506
+ "actions (e.g. s3:*)",
507
+ "Restrict actions to the specific APIs the "
508
+ "function needs",
509
+ )
510
+ elif role.get("has_privilege_escalation"):
511
+ add(
512
+ "HIGH", "privilege_escalation_role",
513
+ "Execution role has privilege escalation "
514
+ "permissions",
515
+ "Remove dangerous IAM permissions",
516
+ )
517
+
518
+ # B.5 Shared role
519
+ if checks.get("shared_role", {}).get("is_shared"):
520
+ add(
521
+ "HIGH", "shared_role",
522
+ "Execution role is shared across "
523
+ "functions",
524
+ "Create unique roles per function",
525
+ )
526
+
527
+ # C.1 VPC
528
+ if not checks.get("vpc_config", {}).get("in_vpc"):
529
+ add(
530
+ "LOW", "no_vpc",
531
+ "Function is not deployed in a VPC",
532
+ "Consider VPC deployment for sensitive "
533
+ "workloads",
534
+ )
535
+
536
+ # C.2 Multi-AZ
537
+ ma = checks.get("multi_az", {})
538
+ if ma.get("applicable") and not ma.get(
539
+ "is_multi_az"
540
+ ):
541
+ add(
542
+ "MEDIUM", "single_az",
543
+ "VPC function deployed in single AZ",
544
+ "Deploy across at least 2 AZs",
545
+ )
546
+
547
+ # C.3 SG egress
548
+ sg = checks.get("security_groups", {})
549
+ if sg.get("applicable") and sg.get(
550
+ "unrestricted_egress"
551
+ ):
552
+ add(
553
+ "MEDIUM", "unrestricted_egress",
554
+ "Security group allows unrestricted "
555
+ "egress",
556
+ "Restrict outbound rules to required "
557
+ "destinations",
558
+ )
559
+
560
+ # D.1 Log group
561
+ lg = checks.get("log_group", {})
562
+ if not lg.get("exists"):
563
+ add(
564
+ "MEDIUM", "missing_log_group",
565
+ "CloudWatch log group does not exist",
566
+ "Invoke function or create log group "
567
+ "manually",
568
+ )
569
+ elif not lg.get("has_retention"):
570
+ add(
571
+ "MEDIUM", "no_log_retention",
572
+ "Log group has no retention policy",
573
+ "Set a log retention period",
574
+ )
575
+
576
+ # D.2 Reserved concurrency
577
+ rc = checks.get("reserved_concurrency", {})
578
+ if not rc.get("configured"):
579
+ add(
580
+ "LOW", "no_reserved_concurrency",
581
+ "No reserved concurrency configured",
582
+ "Set reserved concurrency to prevent "
583
+ "account-wide throttling (especially for "
584
+ "public functions)",
585
+ )
586
+ elif rc.get("is_disabled"):
587
+ add(
588
+ "INFO", "disabled_function",
589
+ "Function is disabled "
590
+ "(reserved concurrency = 0)",
591
+ "Remove or increase reserved concurrency "
592
+ "if the function should be active",
593
+ )
594
+
595
+ # E.1 Code signing — only applies to Zip-packaged functions.
596
+ # Container image functions get applicable=False from the
597
+ # checker and are skipped here (signing is N/A for images).
598
+ cs = checks.get("code_signing", {})
599
+ if cs.get("applicable", True):
600
+ if not cs.get("configured"):
601
+ add(
602
+ "MEDIUM", "no_code_signing",
603
+ "No code signing configuration",
604
+ "Enable code signing for deployment "
605
+ "integrity",
606
+ )
607
+ elif not cs.get("is_enforced"):
608
+ add(
609
+ "LOW", "code_signing_warn",
610
+ "Code signing uses Warn policy instead "
611
+ "of Enforce",
612
+ "Set UntrustedArtifactOnDeployment to "
613
+ "Enforce",
614
+ )
615
+
616
+ # E.2 ESM
617
+ esm = checks.get("event_source_mappings", {})
618
+ if (
619
+ esm.get("has_mappings")
620
+ and esm.get(
621
+ "missing_failure_dest_count", 0
622
+ ) > 0
623
+ ):
624
+ add(
625
+ "MEDIUM", "esm_no_failure_dest",
626
+ f"{esm['missing_failure_dest_count']} "
627
+ "event source mapping(s) without "
628
+ "failure destination",
629
+ "Configure OnFailure destination for "
630
+ "each ESM",
631
+ )
632
+
633
+ # Composite: Public + No concurrency
634
+ is_public = (
635
+ checks.get("resource_policy", {}).get(
636
+ "is_public"
637
+ )
638
+ or checks.get("function_url", {}).get(
639
+ "is_public"
640
+ )
641
+ )
642
+ if is_public and not rc.get("configured"):
643
+ add(
644
+ "CRITICAL", "public_no_concurrency",
645
+ "Public function without reserved "
646
+ "concurrency - financial exhaustion risk",
647
+ "Set reserved concurrency and review "
648
+ "public access",
649
+ )
650
+
651
+ # Composite: Public URL + CORS wildcard
652
+ if (
653
+ checks.get("function_url", {}).get(
654
+ "is_public"
655
+ )
656
+ and checks.get(
657
+ "function_url_cors", {}
658
+ ).get("allow_all_origins")
659
+ ):
660
+ add(
661
+ "CRITICAL", "public_url_cors_wildcard",
662
+ "Public function URL with wildcard "
663
+ "CORS - maximally exposed",
664
+ "Restrict CORS origins and add "
665
+ "authentication",
666
+ )
667
+
668
+ # Surface checks that errored (e.g. AccessDenied) so the user
669
+ # knows the corresponding finding is "could not evaluate" rather
670
+ # than "confirmed clean". Without this, an under-permissioned
671
+ # scanning role yields an artificially clean report.
672
+ for check_name, check_val in checks.items():
673
+ if isinstance(check_val, dict) and check_val.get("error"):
674
+ add(
675
+ "ERROR", "check_failed",
676
+ f"Check '{check_name}' could not run: "
677
+ f"{check_val['error']}",
678
+ "Grant the scanning role the missing permission, "
679
+ "or scope the scan to functions you can audit.",
680
+ )
681
+
682
+ return issues
683
+
684
+ def _error_result(
685
+ self, func_name: str, error_msg: str
686
+ ) -> Dict[str, Any]:
687
+ """Create safe error result dict."""
688
+ return {
689
+ "function_name": func_name,
690
+ "function_arn": "",
691
+ "error": error_msg,
692
+ "scan_error": True,
693
+ "issues": [{
694
+ "severity": "ERROR",
695
+ "issue_type": "scan_error",
696
+ "description": (
697
+ f"Scan failed: {error_msg}"
698
+ ),
699
+ "recommendation": (
700
+ "Check permissions and retry"
701
+ ),
702
+ }],
703
+ "issue_count": 1,
704
+ "security_score": None,
705
+ "compliance_status": {},
706
+ }
707
+
708
+ # ============================================================
709
+ # Parallel Scanning
710
+ # ============================================================
711
+
712
+ def scan_all_functions(
713
+ self, functions: List[Dict] = None
714
+ ) -> List[Dict[str, Any]]:
715
+ """Scan all functions in parallel."""
716
+ if functions is None:
717
+ functions = self.get_all_functions()
718
+
719
+ if not functions:
720
+ return []
721
+
722
+ # Collect all role ARNs for B.5 shared role check
723
+ all_role_arns = [
724
+ f.get("Role", "") for f in functions
725
+ ]
726
+
727
+ results = []
728
+ with Progress(
729
+ SpinnerColumn(),
730
+ TextColumn(
731
+ "[progress.description]"
732
+ "{task.description}"
733
+ ),
734
+ TextColumn(
735
+ "[cyan]{task.completed}/{task.total}"
736
+ ),
737
+ console=self.console,
738
+ disable=self.quiet,
739
+ ) as progress:
740
+ task = progress.add_task(
741
+ "Scanning Lambda functions...",
742
+ total=len(functions),
743
+ )
744
+
745
+ with ThreadPoolExecutor(
746
+ max_workers=self.max_workers
747
+ ) as executor:
748
+ future_to_func = {
749
+ executor.submit(
750
+ self.scan_function,
751
+ func,
752
+ all_role_arns,
753
+ progress,
754
+ task,
755
+ ): func
756
+ for func in functions
757
+ }
758
+
759
+ for future in as_completed(
760
+ future_to_func
761
+ ):
762
+ func = future_to_func[future]
763
+ try:
764
+ result = future.result()
765
+ results.append(result)
766
+ except Exception as e:
767
+ func_name = func.get(
768
+ "FunctionName", "unknown"
769
+ )
770
+ self.logger.error(
771
+ "Scan failed for "
772
+ f"{func_name}: {e}"
773
+ )
774
+ results.append(
775
+ self._error_result(
776
+ func_name, str(e)
777
+ )
778
+ )
779
+
780
+ # Sort by score ascending (worst first)
781
+ results.sort(
782
+ key=lambda r: (
783
+ r.get("security_score") is not None,
784
+ r.get("security_score", 0) or 0,
785
+ )
786
+ )
787
+
788
+ return results
789
+
790
+ # ============================================================
791
+ # Report Generation
792
+ # ============================================================
793
+
794
+ def generate_reports(
795
+ self,
796
+ results: List[Dict],
797
+ output_format: str = "all",
798
+ ) -> Dict[str, str]:
799
+ """Generate reports in specified format."""
800
+ report_files = {}
801
+ timestamp = datetime.now().strftime(
802
+ "%Y%m%d_%H%M%S"
803
+ )
804
+
805
+ if output_format in ("json", "all"):
806
+ report_files["json"] = self._export_json(
807
+ results, timestamp
808
+ )
809
+ if output_format in ("csv", "all"):
810
+ report_files["csv"] = self._export_csv(
811
+ results, timestamp
812
+ )
813
+ if output_format in ("html", "all"):
814
+ report_files["html"] = self._export_html(
815
+ results, timestamp
816
+ )
817
+ # Always export compliance
818
+ report_files["compliance"] = (
819
+ self._export_compliance(results, timestamp)
820
+ )
821
+
822
+ return report_files
823
+
824
+ def _export_json(
825
+ self, results: List[Dict], timestamp: str
826
+ ) -> str:
827
+ filepath = os.path.join(
828
+ self.output_dir,
829
+ f"lambda_scan_{self.region}_{timestamp}.json",
830
+ )
831
+ # Match the documented schema and the s3/ec2 scanner family:
832
+ # a top-level object with a summary and the per-function results.
833
+ payload = {
834
+ "summary": {
835
+ "scan_time": datetime.now().isoformat(),
836
+ **self._build_summary(results),
837
+ },
838
+ "results": results,
839
+ }
840
+ with open(filepath, "w", encoding="utf-8") as f:
841
+ json.dump(
842
+ payload, f, indent=2, default=str
843
+ )
844
+ return filepath
845
+
846
+ def _export_csv(
847
+ self, results: List[Dict], timestamp: str
848
+ ) -> str:
849
+ filepath = os.path.join(
850
+ self.output_dir,
851
+ f"lambda_scan_{self.region}_{timestamp}.csv",
852
+ )
853
+ frameworks = [
854
+ "AWS-FSBP", "CIS", "PCI-DSS-v4.0.1",
855
+ "HIPAA", "SOC2", "ISO27001", "ISO27017",
856
+ "ISO27018", "GDPR", "NIST-800-53",
857
+ ]
858
+ fieldnames = [
859
+ "function_name", "function_arn", "region",
860
+ "runtime", "security_score", "issue_count",
861
+ "is_public",
862
+ ] + [f"{fw}_compliant" for fw in frameworks]
863
+
864
+ with open(
865
+ filepath, "w", newline="",
866
+ encoding="utf-8",
867
+ ) as f:
868
+ writer = csv.DictWriter(
869
+ f, fieldnames=fieldnames,
870
+ extrasaction="ignore",
871
+ )
872
+ writer.writeheader()
873
+ for r in results:
874
+ row = {
875
+ "function_name": r.get(
876
+ "function_name"
877
+ ),
878
+ "function_arn": r.get(
879
+ "function_arn"
880
+ ),
881
+ "region": r.get("region"),
882
+ "runtime": r.get(
883
+ "runtime", {}
884
+ ).get("runtime", "N/A"),
885
+ "security_score": r.get(
886
+ "security_score"
887
+ ),
888
+ "issue_count": r.get(
889
+ "issue_count", 0
890
+ ),
891
+ "is_public": r.get(
892
+ "is_public", False
893
+ ),
894
+ }
895
+ cs = r.get("compliance_status", {})
896
+ for fw in frameworks:
897
+ row[f"{fw}_compliant"] = cs.get(
898
+ fw, {}
899
+ ).get("is_compliant", False)
900
+ writer.writerow(row)
901
+ return filepath
902
+
903
+ def _export_html(
904
+ self, results: List[Dict], timestamp: str
905
+ ) -> str:
906
+ filepath = os.path.join(
907
+ self.output_dir,
908
+ f"lambda_scan_{self.region}_{timestamp}.html",
909
+ )
910
+ summary = self._build_summary(results)
911
+ self.html_reporter.generate_report(
912
+ results, summary, filepath
913
+ )
914
+ return filepath
915
+
916
+ def _export_compliance(
917
+ self, results: List[Dict], timestamp: str
918
+ ) -> str:
919
+ filepath = os.path.join(
920
+ self.output_dir,
921
+ "lambda_compliance_"
922
+ f"{self.region}_{timestamp}.json",
923
+ )
924
+ valid = [
925
+ r for r in results
926
+ if not r.get("scan_error", False)
927
+ ]
928
+ compliance_data = {
929
+ "account_id": self.account_id,
930
+ "region": self.region,
931
+ "scan_timestamp": timestamp,
932
+ "total_functions": len(results),
933
+ "scanned_functions": len(valid),
934
+ "frameworks": {},
935
+ }
936
+ frameworks = [
937
+ "AWS-FSBP", "CIS", "PCI-DSS-v4.0.1",
938
+ "HIPAA", "SOC2", "ISO27001", "ISO27017",
939
+ "ISO27018", "GDPR", "NIST-800-53",
940
+ ]
941
+ for fw in frameworks:
942
+ compliant = 0
943
+ total = 0
944
+ for r in valid:
945
+ fw_status = r.get(
946
+ "compliance_status", {}
947
+ ).get(fw, {})
948
+ if fw_status:
949
+ total += 1
950
+ if fw_status.get("is_compliant"):
951
+ compliant += 1
952
+ compliance_data["frameworks"][fw] = {
953
+ "compliant_functions": compliant,
954
+ "total_functions": total,
955
+ "compliance_percentage": round(
956
+ compliant / total * 100, 1
957
+ ) if total > 0 else 0,
958
+ }
959
+
960
+ with open(
961
+ filepath, "w", encoding="utf-8"
962
+ ) as f:
963
+ json.dump(compliance_data, f, indent=2)
964
+ return filepath
965
+
966
+ def _build_summary(
967
+ self, results: List[Dict]
968
+ ) -> Dict[str, Any]:
969
+ valid = [
970
+ r for r in results
971
+ if not r.get("scan_error", False)
972
+ ]
973
+ scores = [
974
+ r.get("security_score", 0)
975
+ for r in valid
976
+ if r.get("security_score") is not None
977
+ ]
978
+ return {
979
+ "account_id": self.account_id,
980
+ "region": self.region,
981
+ "total_functions": len(results),
982
+ "scanned_functions": len(valid),
983
+ "error_functions": (
984
+ len(results) - len(valid)
985
+ ),
986
+ "average_security_score": round(
987
+ sum(scores) / len(scores), 1
988
+ ) if scores else 0,
989
+ "public_functions": sum(
990
+ 1 for r in valid
991
+ if r.get("is_public", False)
992
+ ),
993
+ "functions_with_secrets": sum(
994
+ 1 for r in valid
995
+ if r.get(
996
+ "environment_secrets", {}
997
+ ).get("has_secrets", False)
998
+ ),
999
+ "functions_with_deprecated_runtime": sum(
1000
+ 1 for r in valid
1001
+ if r.get("runtime", {}).get(
1002
+ "status"
1003
+ ) in ("deprecated", "blocked")
1004
+ ),
1005
+ }
1006
+
1007
+ # ============================================================
1008
+ # Console Summary
1009
+ # ============================================================
1010
+
1011
+ def print_summary(
1012
+ self, results: List[Dict]
1013
+ ) -> None:
1014
+ """Print Rich formatted console summary."""
1015
+ summary = self._build_summary(results)
1016
+
1017
+ # Overall Metrics
1018
+ table = Table(title="Overall Metrics")
1019
+ table.add_column("Metric", style="cyan")
1020
+ table.add_column("Value", justify="right")
1021
+
1022
+ table.add_row(
1023
+ "Total Functions",
1024
+ str(summary["total_functions"]),
1025
+ )
1026
+ table.add_row(
1027
+ "Scanned",
1028
+ str(summary["scanned_functions"]),
1029
+ )
1030
+ table.add_row(
1031
+ "Errors",
1032
+ str(summary["error_functions"]),
1033
+ )
1034
+ table.add_row(
1035
+ "Average Score",
1036
+ f"{summary['average_security_score']:.1f}",
1037
+ )
1038
+ table.add_row(
1039
+ "Public Functions",
1040
+ str(summary["public_functions"]),
1041
+ )
1042
+ table.add_row(
1043
+ "Functions with Secrets",
1044
+ str(summary["functions_with_secrets"]),
1045
+ )
1046
+ table.add_row(
1047
+ "Deprecated Runtimes",
1048
+ str(
1049
+ summary[
1050
+ "functions_with_deprecated_runtime"
1051
+ ]
1052
+ ),
1053
+ )
1054
+ self.console.print(table)
1055
+
1056
+ # Lowest Scoring Functions (top 5)
1057
+ valid = [
1058
+ r for r in results
1059
+ if not r.get("scan_error", False)
1060
+ ]
1061
+ if valid:
1062
+ worst = sorted(
1063
+ valid,
1064
+ key=lambda r: (
1065
+ r.get("security_score", 0) or 0
1066
+ ),
1067
+ )[:5]
1068
+
1069
+ table2 = Table(
1070
+ title="Lowest Scoring Functions"
1071
+ )
1072
+ table2.add_column(
1073
+ "Function", style="cyan"
1074
+ )
1075
+ table2.add_column(
1076
+ "Score", justify="right"
1077
+ )
1078
+ table2.add_column(
1079
+ "Issues", justify="right"
1080
+ )
1081
+ table2.add_column("Runtime")
1082
+
1083
+ for r in worst:
1084
+ score = (
1085
+ r.get("security_score", 0) or 0
1086
+ )
1087
+ color = (
1088
+ "red" if score < 50
1089
+ else "yellow" if score < 70
1090
+ else "green"
1091
+ )
1092
+ table2.add_row(
1093
+ r.get("function_name", ""),
1094
+ f"[{color}]{score}[/{color}]",
1095
+ str(r.get("issue_count", 0)),
1096
+ r.get("runtime", {}).get(
1097
+ "runtime", "N/A"
1098
+ ),
1099
+ )
1100
+ self.console.print(table2)
1101
+
1102
+ # Compliance Summary
1103
+ frameworks = [
1104
+ "AWS-FSBP", "CIS", "PCI-DSS-v4.0.1",
1105
+ "HIPAA", "SOC2", "ISO27001", "ISO27017",
1106
+ "ISO27018", "GDPR", "NIST-800-53",
1107
+ ]
1108
+ table3 = Table(title="Compliance Summary")
1109
+ table3.add_column("Framework", style="cyan")
1110
+ table3.add_column(
1111
+ "Compliant", justify="right"
1112
+ )
1113
+ table3.add_column(
1114
+ "Non-Compliant", justify="right"
1115
+ )
1116
+ table3.add_column(
1117
+ "Avg %", justify="right"
1118
+ )
1119
+
1120
+ for fw in frameworks:
1121
+ pcts = []
1122
+ compliant = 0
1123
+ total = 0
1124
+ for r in valid:
1125
+ fw_status = r.get(
1126
+ "compliance_status", {}
1127
+ ).get(fw, {})
1128
+ if fw_status:
1129
+ total += 1
1130
+ if fw_status.get("is_compliant"):
1131
+ compliant += 1
1132
+ pcts.append(
1133
+ fw_status.get(
1134
+ "compliance_percentage",
1135
+ 0,
1136
+ )
1137
+ )
1138
+ avg_pct = (
1139
+ round(sum(pcts) / len(pcts), 1)
1140
+ if pcts else 0
1141
+ )
1142
+ color = (
1143
+ "red" if avg_pct < 50
1144
+ else "yellow" if avg_pct < 70
1145
+ else "green"
1146
+ )
1147
+ table3.add_row(
1148
+ fw,
1149
+ str(compliant),
1150
+ str(total - compliant),
1151
+ f"[{color}]{avg_pct}%[/{color}]",
1152
+ )
1153
+
1154
+ self.console.print(table3)