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,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)
|