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,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
+ }