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,454 @@
|
|
|
1
|
+
"""Function configuration security checks (A.1-A.7).
|
|
2
|
+
|
|
3
|
+
Checks runtime status, timeout, environment variable secrets,
|
|
4
|
+
ephemeral storage, external layers, X-Ray tracing, and
|
|
5
|
+
dead letter queue configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
from .base import BaseChecker
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("lambda_security_scanner")
|
|
16
|
+
|
|
17
|
+
# A.1 — Runtime classification lists.
|
|
18
|
+
# Source: https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html
|
|
19
|
+
# Classification rule: block-function-update date has already passed.
|
|
20
|
+
# Verified 2026-05-30 against the live AWS runtimes page. AWS has extended
|
|
21
|
+
# the "block function update" date to 2027-03-03 for the modern legacy
|
|
22
|
+
# runtimes (nodejs14-20, python3.7-3.9, dotnet6, java8, go1.x, provided,
|
|
23
|
+
# ruby2.7, ruby3.2), so those are DEPRECATED (still updatable), not blocked.
|
|
24
|
+
# Only runtimes whose block-update date has actually passed are BLOCKED.
|
|
25
|
+
# This boundary is AWS-policy dependent and shifts over time; re-verify
|
|
26
|
+
# against the runtimes page each release. test_function_config locks the
|
|
27
|
+
# current mapping so any change is deliberate.
|
|
28
|
+
BLOCKED_RUNTIMES = {
|
|
29
|
+
"nodejs", # Block update: Oct 31, 2016
|
|
30
|
+
"nodejs4.3", # Block update: Mar 5, 2020
|
|
31
|
+
"nodejs4.3-edge", # Block update: Apr 30, 2019
|
|
32
|
+
"nodejs6.10", # Block update: Aug 12, 2019
|
|
33
|
+
"nodejs8.10", # Block update: Mar 6, 2020
|
|
34
|
+
"nodejs10.x", # Block update: Feb 14, 2022
|
|
35
|
+
"nodejs12.x", # Block update: Apr 30, 2023
|
|
36
|
+
"python2.7", # Block update: May 30, 2022
|
|
37
|
+
"python3.6", # Block update: Aug 29, 2022
|
|
38
|
+
"dotnetcore1.0", # Block update: Jul 30, 2019
|
|
39
|
+
"dotnetcore2.0", # Block update: May 30, 2019
|
|
40
|
+
"dotnetcore2.1", # Block update: Apr 13, 2022
|
|
41
|
+
"dotnetcore3.1", # Block update: May 3, 2023
|
|
42
|
+
"dotnet5.0", # Container only, deprecated May 10, 2022
|
|
43
|
+
"dotnet7", # Container only, deprecated May 14, 2024
|
|
44
|
+
"ruby2.5", # Block update: Mar 31, 2022
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Deprecated runtimes: past deprecation date, but block-function-update
|
|
48
|
+
# has NOT yet been enforced (AWS extended block-update to 2027-03-03
|
|
49
|
+
# for most legacy runtimes). Function still updatable today.
|
|
50
|
+
DEPRECATED_RUNTIMES = {
|
|
51
|
+
"nodejs14.x", # Deprecated: Dec 4, 2023 (block update: Mar 3, 2027)
|
|
52
|
+
"nodejs16.x", # Deprecated: Jun 12, 2024
|
|
53
|
+
"nodejs18.x", # Deprecated: Sep 1, 2025
|
|
54
|
+
"nodejs20.x", # Deprecated: Apr 30, 2026
|
|
55
|
+
"python3.7", # Deprecated: Dec 4, 2023
|
|
56
|
+
"python3.8", # Deprecated: Oct 14, 2024
|
|
57
|
+
"python3.9", # Deprecated: Dec 15, 2025
|
|
58
|
+
"dotnet6", # Deprecated: Dec 20, 2024
|
|
59
|
+
"java8", # AL1; deprecated: Jan 8, 2024
|
|
60
|
+
"go1.x", # Deprecated: Jan 8, 2024
|
|
61
|
+
"provided", # Deprecated: Jan 8, 2024
|
|
62
|
+
"ruby2.7", # Deprecated: Dec 7, 2023
|
|
63
|
+
"ruby3.2", # Deprecated: Mar 31, 2026
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# Near-EOL runtimes (supported today, deprecate within ~12 months).
|
|
67
|
+
# Values are official AWS deprecation dates.
|
|
68
|
+
NEAR_EOL_RUNTIMES = {
|
|
69
|
+
"provided.al2": "2026-07-31",
|
|
70
|
+
"python3.10": "2026-10-31",
|
|
71
|
+
"dotnet8": "2026-11-10",
|
|
72
|
+
"ruby3.3": "2027-03-31",
|
|
73
|
+
"nodejs22.x": "2027-04-30",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
# A.3 — Secret detection patterns
|
|
77
|
+
SECRET_NAME_PATTERNS = [
|
|
78
|
+
re.compile(r"(?i)password"),
|
|
79
|
+
re.compile(r"(?i)secret"),
|
|
80
|
+
re.compile(r"(?i)api_?key"),
|
|
81
|
+
re.compile(r"(?i)auth_?token"),
|
|
82
|
+
re.compile(r"(?i)access_?key"),
|
|
83
|
+
re.compile(r"(?i)private_?key"),
|
|
84
|
+
re.compile(r"(?i)database_?url"),
|
|
85
|
+
re.compile(r"(?i)connection_?string"),
|
|
86
|
+
re.compile(r"(?i)credentials"),
|
|
87
|
+
re.compile(r"(?i)token"),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
SECRET_VALUE_PATTERNS = [
|
|
91
|
+
(
|
|
92
|
+
"AWS_ACCESS_KEY",
|
|
93
|
+
re.compile(r"(?:AKIA|ASIA)[0-9A-Z]{16}"),
|
|
94
|
+
),
|
|
95
|
+
(
|
|
96
|
+
"GITHUB_TOKEN",
|
|
97
|
+
re.compile(r"ghp_[a-zA-Z0-9]{36,}"),
|
|
98
|
+
),
|
|
99
|
+
(
|
|
100
|
+
"GITHUB_PAT",
|
|
101
|
+
re.compile(r"github_pat_[a-zA-Z0-9_]{82}"),
|
|
102
|
+
),
|
|
103
|
+
(
|
|
104
|
+
"GITLAB_TOKEN",
|
|
105
|
+
re.compile(r"glpat-[a-zA-Z0-9\-]{20,}"),
|
|
106
|
+
),
|
|
107
|
+
(
|
|
108
|
+
"STRIPE_KEY",
|
|
109
|
+
re.compile(r"sk_live_[a-zA-Z0-9]{24,}"),
|
|
110
|
+
),
|
|
111
|
+
(
|
|
112
|
+
"STRIPE_RESTRICTED_KEY",
|
|
113
|
+
re.compile(r"rk_live_[a-zA-Z0-9]{24,}"),
|
|
114
|
+
),
|
|
115
|
+
(
|
|
116
|
+
"SLACK_TOKEN",
|
|
117
|
+
re.compile(r"xox[bpors]-[a-zA-Z0-9\-]+"),
|
|
118
|
+
),
|
|
119
|
+
(
|
|
120
|
+
"SLACK_APP_TOKEN",
|
|
121
|
+
re.compile(r"xapp-[0-9]-[a-zA-Z0-9]+"),
|
|
122
|
+
),
|
|
123
|
+
(
|
|
124
|
+
"PRIVATE_KEY",
|
|
125
|
+
re.compile(
|
|
126
|
+
r"-----BEGIN\s+(?:RSA\s+|EC\s+|DSA\s+"
|
|
127
|
+
r"|OPENSSH\s+)?PRIVATE\s+KEY-----"
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
"CONNECTION_STRING",
|
|
132
|
+
re.compile(
|
|
133
|
+
r"(?:mongodb|postgres|mysql|redis|amqp|mssql)"
|
|
134
|
+
r"(?:\+\w+)?://[^:]+:[^@]+@",
|
|
135
|
+
re.IGNORECASE,
|
|
136
|
+
),
|
|
137
|
+
),
|
|
138
|
+
(
|
|
139
|
+
"ANTHROPIC_KEY",
|
|
140
|
+
re.compile(r"sk-ant-[a-zA-Z0-9\-]{40,}"),
|
|
141
|
+
),
|
|
142
|
+
(
|
|
143
|
+
"OPENAI_KEY_PROJECT",
|
|
144
|
+
re.compile(r"sk-proj-[a-zA-Z0-9_\-]{48,}"),
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
"OPENAI_KEY_SVCACCT",
|
|
148
|
+
re.compile(r"sk-svcacct-[a-zA-Z0-9_\-]{48,}"),
|
|
149
|
+
),
|
|
150
|
+
(
|
|
151
|
+
"OPENAI_KEY",
|
|
152
|
+
re.compile(r"sk-[a-zA-Z0-9]{48,}"),
|
|
153
|
+
),
|
|
154
|
+
(
|
|
155
|
+
"SENDGRID_KEY",
|
|
156
|
+
re.compile(
|
|
157
|
+
r"SG\.[a-zA-Z0-9_\-]{22}\.[a-zA-Z0-9_\-]{43}"
|
|
158
|
+
),
|
|
159
|
+
),
|
|
160
|
+
(
|
|
161
|
+
"NPM_TOKEN",
|
|
162
|
+
re.compile(r"npm_[a-zA-Z0-9]{36,}"),
|
|
163
|
+
),
|
|
164
|
+
]
|
|
165
|
+
|
|
166
|
+
# Values that mean the env var holds a *reference* to a secret store
|
|
167
|
+
# (the AWS-recommended pattern), not a plaintext secret. A secret-looking
|
|
168
|
+
# variable NAME pointing at one of these must NOT be flagged.
|
|
169
|
+
SAFE_REFERENCE_PATTERNS = [
|
|
170
|
+
re.compile(r"^arn:aws[\w\-]*:secretsmanager:", re.IGNORECASE),
|
|
171
|
+
re.compile(r"^arn:aws[\w\-]*:ssm:", re.IGNORECASE),
|
|
172
|
+
re.compile(r"^arn:aws[\w\-]*:kms:", re.IGNORECASE),
|
|
173
|
+
# CloudFormation dynamic reference
|
|
174
|
+
re.compile(r"^\{\{resolve:(?:secretsmanager|ssm)", re.IGNORECASE),
|
|
175
|
+
# SSM Parameter Store style path, e.g. /myapp/db/password
|
|
176
|
+
re.compile(r"^/[\w./\-]+$"),
|
|
177
|
+
]
|
|
178
|
+
|
|
179
|
+
# Obviously non-secret values: config flags, environment names, ports.
|
|
180
|
+
_NON_SECRET_LITERALS = {
|
|
181
|
+
"true", "false", "yes", "no", "none", "null", "enabled", "disabled",
|
|
182
|
+
"prod", "production", "dev", "development", "staging", "test", "local",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _is_safe_reference(value: str) -> bool:
|
|
187
|
+
"""True if the value references a managed secret store, not a secret."""
|
|
188
|
+
return any(p.search(value) for p in SAFE_REFERENCE_PATTERNS)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _is_trivial_value(value: str) -> bool:
|
|
192
|
+
"""True if the value is too simple to be a real secret (flag, port...)."""
|
|
193
|
+
v = value.strip()
|
|
194
|
+
if len(v) <= 4:
|
|
195
|
+
return True
|
|
196
|
+
if v.lower() in _NON_SECRET_LITERALS:
|
|
197
|
+
return True
|
|
198
|
+
try:
|
|
199
|
+
float(v) # ports, TTLs, sizes
|
|
200
|
+
return True
|
|
201
|
+
except ValueError:
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
class FunctionConfigChecker(BaseChecker):
|
|
206
|
+
"""Security checks for Lambda function configuration.
|
|
207
|
+
|
|
208
|
+
Implements checks A.1 through A.7 from the Lambda
|
|
209
|
+
scanner design specification. All checks operate on the
|
|
210
|
+
function configuration dict returned by list_functions;
|
|
211
|
+
no additional API calls are needed.
|
|
212
|
+
"""
|
|
213
|
+
|
|
214
|
+
def check_runtime(
|
|
215
|
+
self,
|
|
216
|
+
function_config: Dict,
|
|
217
|
+
account_id: str = None,
|
|
218
|
+
) -> Dict:
|
|
219
|
+
"""A.1 — Check for deprecated or end-of-life runtime.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
function_config: Lambda function configuration dict
|
|
223
|
+
account_id: AWS account ID (unused, reserved)
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
Dict with runtime, package_type, status, eol_date
|
|
227
|
+
"""
|
|
228
|
+
package_type = function_config.get(
|
|
229
|
+
"PackageType", "Zip"
|
|
230
|
+
)
|
|
231
|
+
runtime = function_config.get("Runtime", "")
|
|
232
|
+
|
|
233
|
+
if package_type == "Image":
|
|
234
|
+
return {
|
|
235
|
+
"runtime": None,
|
|
236
|
+
"package_type": package_type,
|
|
237
|
+
"status": "supported",
|
|
238
|
+
"eol_date": None,
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if runtime in BLOCKED_RUNTIMES:
|
|
242
|
+
status = "blocked"
|
|
243
|
+
eol_date = None
|
|
244
|
+
elif runtime in DEPRECATED_RUNTIMES:
|
|
245
|
+
status = "deprecated"
|
|
246
|
+
eol_date = None
|
|
247
|
+
elif runtime in NEAR_EOL_RUNTIMES:
|
|
248
|
+
status = "near_eol"
|
|
249
|
+
eol_date = NEAR_EOL_RUNTIMES[runtime]
|
|
250
|
+
else:
|
|
251
|
+
status = "supported"
|
|
252
|
+
eol_date = None
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
"runtime": runtime,
|
|
256
|
+
"package_type": package_type,
|
|
257
|
+
"status": status,
|
|
258
|
+
"eol_date": eol_date,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
def check_timeout(self, function_config: Dict) -> Dict:
|
|
262
|
+
"""A.2 — Check for maximum timeout setting.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
function_config: Lambda function configuration dict
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
Dict with timeout_seconds, is_max_timeout
|
|
269
|
+
"""
|
|
270
|
+
timeout = function_config.get("Timeout", 3)
|
|
271
|
+
return {
|
|
272
|
+
"timeout_seconds": timeout,
|
|
273
|
+
"is_max_timeout": timeout >= 900,
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
def check_environment_secrets(
|
|
277
|
+
self, function_config: Dict
|
|
278
|
+
) -> Dict:
|
|
279
|
+
"""A.3 — Scan environment variables for secrets.
|
|
280
|
+
|
|
281
|
+
Checks both variable names (against known secret
|
|
282
|
+
patterns) and values (against known credential
|
|
283
|
+
formats).
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
function_config: Lambda function configuration dict
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Dict with has_env_vars, env_var_count,
|
|
290
|
+
has_secrets, secret_names, secret_values,
|
|
291
|
+
kms_key_arn, has_kms_key
|
|
292
|
+
"""
|
|
293
|
+
env = function_config.get("Environment", {})
|
|
294
|
+
variables = env.get("Variables", {})
|
|
295
|
+
kms_key_arn = function_config.get(
|
|
296
|
+
"KMSKeyArn", None
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
secret_names: List[str] = []
|
|
300
|
+
secret_values: List[Dict] = []
|
|
301
|
+
|
|
302
|
+
for name, value in variables.items():
|
|
303
|
+
value_str = str(value)
|
|
304
|
+
|
|
305
|
+
# 1. A value matching a known credential format is a definitive
|
|
306
|
+
# plaintext secret regardless of the variable name.
|
|
307
|
+
matched_value = None
|
|
308
|
+
for label, pattern in SECRET_VALUE_PATTERNS:
|
|
309
|
+
if pattern.search(value_str):
|
|
310
|
+
matched_value = label
|
|
311
|
+
break
|
|
312
|
+
if matched_value:
|
|
313
|
+
secret_values.append(
|
|
314
|
+
{"name": name, "type": matched_value}
|
|
315
|
+
)
|
|
316
|
+
if name not in secret_names:
|
|
317
|
+
secret_names.append(name)
|
|
318
|
+
continue
|
|
319
|
+
|
|
320
|
+
# 2. A secret-looking variable NAME is only a finding when the
|
|
321
|
+
# value is not a managed-secret reference (Secrets Manager /
|
|
322
|
+
# SSM / KMS) and not a trivial config value. This avoids
|
|
323
|
+
# flagging the AWS-recommended pattern of storing an ARN or
|
|
324
|
+
# parameter path in an env var.
|
|
325
|
+
if any(p.search(name) for p in SECRET_NAME_PATTERNS):
|
|
326
|
+
if _is_safe_reference(value_str) or _is_trivial_value(
|
|
327
|
+
value_str
|
|
328
|
+
):
|
|
329
|
+
continue
|
|
330
|
+
if name not in secret_names:
|
|
331
|
+
secret_names.append(name)
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
"has_env_vars": len(variables) > 0,
|
|
335
|
+
"env_var_count": len(variables),
|
|
336
|
+
"has_secrets": len(secret_names) > 0,
|
|
337
|
+
"secret_names": secret_names,
|
|
338
|
+
"secret_values": secret_values,
|
|
339
|
+
"kms_key_arn": kms_key_arn,
|
|
340
|
+
"has_kms_key": kms_key_arn is not None,
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
def check_ephemeral_storage(
|
|
344
|
+
self, function_config: Dict
|
|
345
|
+
) -> Dict:
|
|
346
|
+
"""A.4 — Check for large ephemeral storage.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
function_config: Lambda function configuration dict
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
Dict with size_mb, is_large
|
|
353
|
+
"""
|
|
354
|
+
ephemeral = function_config.get(
|
|
355
|
+
"EphemeralStorage", {}
|
|
356
|
+
)
|
|
357
|
+
size_mb = ephemeral.get("Size", 512)
|
|
358
|
+
return {
|
|
359
|
+
"size_mb": size_mb,
|
|
360
|
+
"is_large": size_mb > 512,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
def check_layers(
|
|
364
|
+
self, function_config: Dict, account_id: str
|
|
365
|
+
) -> Dict:
|
|
366
|
+
"""A.5 — Check for external Lambda layers.
|
|
367
|
+
|
|
368
|
+
Flags layers whose account ID differs from the
|
|
369
|
+
scanning account and that are not AWS-managed
|
|
370
|
+
layers (arn:aws:lambda:::awslayer:).
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
function_config: Lambda function configuration dict
|
|
374
|
+
account_id: AWS account ID of the scanner
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
Dict with layer_count, layers,
|
|
378
|
+
has_external_layers, external_layers
|
|
379
|
+
"""
|
|
380
|
+
raw_layers = function_config.get("Layers", [])
|
|
381
|
+
layer_arns = [
|
|
382
|
+
layer.get("Arn", "") for layer in raw_layers
|
|
383
|
+
]
|
|
384
|
+
external_layers: List[str] = []
|
|
385
|
+
|
|
386
|
+
for arn in layer_arns:
|
|
387
|
+
# Skip AWS-managed layers
|
|
388
|
+
if ":lambda:::awslayer:" in arn:
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
# Parse account ID from layer ARN
|
|
392
|
+
# Format: arn:aws:lambda:region:account-id:layer:name:version
|
|
393
|
+
parts = arn.split(":")
|
|
394
|
+
if len(parts) >= 5:
|
|
395
|
+
layer_account = parts[4]
|
|
396
|
+
if (
|
|
397
|
+
layer_account
|
|
398
|
+
and layer_account != account_id
|
|
399
|
+
):
|
|
400
|
+
external_layers.append(arn)
|
|
401
|
+
|
|
402
|
+
return {
|
|
403
|
+
"layer_count": len(layer_arns),
|
|
404
|
+
"layers": layer_arns,
|
|
405
|
+
"has_external_layers": len(external_layers) > 0,
|
|
406
|
+
"external_layers": external_layers,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
def check_tracing(self, function_config: Dict) -> Dict:
|
|
410
|
+
"""A.6 — Check X-Ray tracing configuration.
|
|
411
|
+
|
|
412
|
+
Args:
|
|
413
|
+
function_config: Lambda function configuration dict
|
|
414
|
+
|
|
415
|
+
Returns:
|
|
416
|
+
Dict with mode, enabled
|
|
417
|
+
"""
|
|
418
|
+
tracing = function_config.get(
|
|
419
|
+
"TracingConfig", {}
|
|
420
|
+
)
|
|
421
|
+
mode = tracing.get("Mode", "PassThrough")
|
|
422
|
+
return {
|
|
423
|
+
"mode": mode,
|
|
424
|
+
"enabled": mode == "Active",
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
def check_dead_letter_config(
|
|
428
|
+
self, function_config: Dict
|
|
429
|
+
) -> Dict:
|
|
430
|
+
"""A.7 — Check dead letter queue configuration.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
function_config: Lambda function configuration dict
|
|
434
|
+
|
|
435
|
+
Returns:
|
|
436
|
+
Dict with configured, target_arn, target_type
|
|
437
|
+
"""
|
|
438
|
+
dlc = function_config.get(
|
|
439
|
+
"DeadLetterConfig", {}
|
|
440
|
+
)
|
|
441
|
+
target_arn = dlc.get("TargetArn", None) or None
|
|
442
|
+
|
|
443
|
+
target_type = None
|
|
444
|
+
if target_arn:
|
|
445
|
+
if ":sqs:" in target_arn:
|
|
446
|
+
target_type = "SQS"
|
|
447
|
+
elif ":sns:" in target_arn:
|
|
448
|
+
target_type = "SNS"
|
|
449
|
+
|
|
450
|
+
return {
|
|
451
|
+
"configured": target_arn is not None,
|
|
452
|
+
"target_arn": target_arn,
|
|
453
|
+
"target_type": target_type,
|
|
454
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Logging and monitoring checks for Lambda functions (D.1-D.2)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Dict
|
|
5
|
+
|
|
6
|
+
from botocore.exceptions import ClientError
|
|
7
|
+
|
|
8
|
+
from .base import BaseChecker
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger("lambda_security_scanner")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LoggingMonitoringChecker(BaseChecker):
|
|
14
|
+
"""Check logging and monitoring configuration.
|
|
15
|
+
|
|
16
|
+
Implements checks D.1 (log group/retention) and
|
|
17
|
+
D.2 (reserved concurrency).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def check_log_group(
|
|
21
|
+
self,
|
|
22
|
+
function_name: str,
|
|
23
|
+
region: str,
|
|
24
|
+
function_config: Dict = None,
|
|
25
|
+
) -> Dict:
|
|
26
|
+
"""D.1 - Check CloudWatch log group and retention.
|
|
27
|
+
|
|
28
|
+
Respects Lambda Advanced Logging Controls
|
|
29
|
+
(LoggingConfig.LogGroup) when set; otherwise falls back to
|
|
30
|
+
the default ``/aws/lambda/{function_name}`` log group.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
function_name: Lambda function name.
|
|
34
|
+
region: AWS region name.
|
|
35
|
+
function_config: Optional Lambda function config dict
|
|
36
|
+
from list_functions / get_function_configuration.
|
|
37
|
+
If it contains ``LoggingConfig.LogGroup`` (advanced
|
|
38
|
+
logging GA Nov 2023), that custom group is checked
|
|
39
|
+
instead of the default.
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Dict with exists, retention_days,
|
|
43
|
+
has_retention, kms_encrypted, log_group_name.
|
|
44
|
+
"""
|
|
45
|
+
custom = (
|
|
46
|
+
(function_config or {})
|
|
47
|
+
.get("LoggingConfig", {})
|
|
48
|
+
.get("LogGroup")
|
|
49
|
+
)
|
|
50
|
+
log_group_name = (
|
|
51
|
+
custom or f"/aws/lambda/{function_name}"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
logs = self.get_client("logs", region)
|
|
56
|
+
paginator = logs.get_paginator(
|
|
57
|
+
"describe_log_groups"
|
|
58
|
+
)
|
|
59
|
+
pages = paginator.paginate(
|
|
60
|
+
logGroupNamePrefix=log_group_name
|
|
61
|
+
)
|
|
62
|
+
except ClientError as e:
|
|
63
|
+
return self.handle_client_error(
|
|
64
|
+
e,
|
|
65
|
+
{
|
|
66
|
+
"exists": False,
|
|
67
|
+
"retention_days": None,
|
|
68
|
+
"has_retention": False,
|
|
69
|
+
"kms_encrypted": False,
|
|
70
|
+
"log_group_name": log_group_name,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# Find exact match (not just prefix match)
|
|
75
|
+
all_groups = []
|
|
76
|
+
try:
|
|
77
|
+
for page in pages:
|
|
78
|
+
all_groups.extend(
|
|
79
|
+
page.get("logGroups", [])
|
|
80
|
+
)
|
|
81
|
+
except ClientError as e:
|
|
82
|
+
return self.handle_client_error(
|
|
83
|
+
e,
|
|
84
|
+
{
|
|
85
|
+
"exists": False,
|
|
86
|
+
"retention_days": None,
|
|
87
|
+
"has_retention": False,
|
|
88
|
+
"kms_encrypted": False,
|
|
89
|
+
"log_group_name": log_group_name,
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
for group in all_groups:
|
|
93
|
+
if group.get("logGroupName") == log_group_name:
|
|
94
|
+
retention = group.get("retentionInDays")
|
|
95
|
+
kms_key = group.get("kmsKeyId")
|
|
96
|
+
return {
|
|
97
|
+
"exists": True,
|
|
98
|
+
"retention_days": retention,
|
|
99
|
+
"has_retention": retention is not None,
|
|
100
|
+
"kms_encrypted": bool(kms_key),
|
|
101
|
+
"log_group_name": log_group_name,
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"exists": False,
|
|
106
|
+
"retention_days": None,
|
|
107
|
+
"has_retention": False,
|
|
108
|
+
"kms_encrypted": False,
|
|
109
|
+
"log_group_name": log_group_name,
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
def check_reserved_concurrency(
|
|
113
|
+
self, function_name: str, region: str
|
|
114
|
+
) -> Dict:
|
|
115
|
+
"""D.2 - Check reserved concurrency configuration.
|
|
116
|
+
|
|
117
|
+
ResourceNotFoundException means no concurrency config.
|
|
118
|
+
ReservedConcurrentExecutions == 0 means disabled.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
function_name: Lambda function name.
|
|
122
|
+
region: AWS region name.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Dict with configured, reserved_executions,
|
|
126
|
+
is_disabled.
|
|
127
|
+
"""
|
|
128
|
+
try:
|
|
129
|
+
lambda_client = self.get_client(
|
|
130
|
+
"lambda", region
|
|
131
|
+
)
|
|
132
|
+
response = (
|
|
133
|
+
lambda_client.get_function_concurrency(
|
|
134
|
+
FunctionName=function_name
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
except ClientError as e:
|
|
138
|
+
error_code = e.response.get("Error", {}).get(
|
|
139
|
+
"Code", ""
|
|
140
|
+
)
|
|
141
|
+
if error_code == "ResourceNotFoundException":
|
|
142
|
+
logger.debug(
|
|
143
|
+
"No concurrency config for %s",
|
|
144
|
+
function_name,
|
|
145
|
+
)
|
|
146
|
+
return {
|
|
147
|
+
"configured": False,
|
|
148
|
+
"reserved_executions": None,
|
|
149
|
+
"is_disabled": False,
|
|
150
|
+
}
|
|
151
|
+
return self.handle_client_error(
|
|
152
|
+
e,
|
|
153
|
+
{
|
|
154
|
+
"configured": False,
|
|
155
|
+
"reserved_executions": None,
|
|
156
|
+
"is_disabled": False,
|
|
157
|
+
},
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
reserved = response.get(
|
|
161
|
+
"ReservedConcurrentExecutions"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if reserved is not None:
|
|
165
|
+
return {
|
|
166
|
+
"configured": True,
|
|
167
|
+
"reserved_executions": reserved,
|
|
168
|
+
"is_disabled": reserved == 0,
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
"configured": False,
|
|
173
|
+
"reserved_executions": None,
|
|
174
|
+
"is_disabled": False,
|
|
175
|
+
}
|