preflight-engine 0.1.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.
preflight/rules/aws.py ADDED
@@ -0,0 +1,580 @@
1
+ """AWS-specific detection rules for Terraform plans."""
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ from preflight.models import (
7
+ ActionType,
8
+ Finding,
9
+ ResourceChange,
10
+ RiskFactors,
11
+ Severity,
12
+ )
13
+ from preflight.rules.base import Rule, RuleContext
14
+ from preflight.scoring import score_finding
15
+
16
+ PUBLIC_CIDRS = {"0.0.0.0/0", "::/0"}
17
+ ADMIN_PORTS = {22, 3389}
18
+
19
+ AWS_DATABASE_TYPES = {
20
+ "aws_db_instance",
21
+ "aws_rds_cluster",
22
+ "aws_rds_cluster_instance",
23
+ "aws_dynamodb_table",
24
+ "aws_elasticache_cluster",
25
+ "aws_elasticache_replication_group",
26
+ "aws_redshift_cluster",
27
+ "aws_docdb_cluster",
28
+ "aws_neptune_cluster",
29
+ }
30
+
31
+
32
+ def _ports_include_admin(
33
+ from_port: int | None, to_port: int | None, protocol: str | None
34
+ ) -> tuple[bool, str]:
35
+ """Determine if a port range or protocol includes SSH (22) or RDP (3389)."""
36
+ if protocol in ("-1", "all"):
37
+ return True, "all traffic (including SSH:22 and RDP:3389)"
38
+
39
+ if from_port is None or to_port is None:
40
+ return False, ""
41
+
42
+ if from_port <= 0 and to_port <= 0:
43
+ return True, "all traffic (ports 0-65535)"
44
+
45
+ low = min(from_port, to_port)
46
+ high = max(from_port, to_port)
47
+
48
+ included = []
49
+ if low <= 22 <= high:
50
+ included.append("SSH:22")
51
+ if low <= 3389 <= high:
52
+ included.append("RDP:3389")
53
+
54
+ if included:
55
+ return True, ", ".join(included)
56
+ return False, ""
57
+
58
+
59
+ def _extract_cidrs(rule_data: dict[str, Any]) -> tuple[set[str], bool]:
60
+ """Extract IPv4 and IPv6 CIDRs from an ingress definition, noting if computed."""
61
+ cidrs: set[str] = set()
62
+ is_unknown = False
63
+
64
+ # Check cidr_blocks
65
+ raw_blocks = rule_data.get("cidr_blocks")
66
+ if isinstance(raw_blocks, list):
67
+ for b in raw_blocks:
68
+ if isinstance(b, str):
69
+ cidrs.add(b.strip())
70
+ elif isinstance(raw_blocks, str):
71
+ cidrs.add(raw_blocks.strip())
72
+
73
+ # Check ipv6_cidr_blocks
74
+ raw_v6 = rule_data.get("ipv6_cidr_blocks")
75
+ if isinstance(raw_v6, list):
76
+ for b in raw_v6:
77
+ if isinstance(b, str):
78
+ cidrs.add(b.strip())
79
+ elif isinstance(raw_v6, str):
80
+ cidrs.add(raw_v6.strip())
81
+
82
+ # Check vpc security group rule single cidr attributes
83
+ cidr_ipv4 = rule_data.get("cidr_ipv4")
84
+ if isinstance(cidr_ipv4, str):
85
+ cidrs.add(cidr_ipv4.strip())
86
+ cidr_ipv6 = rule_data.get("cidr_ipv6")
87
+ if isinstance(cidr_ipv6, str):
88
+ cidrs.add(cidr_ipv6.strip())
89
+
90
+ return cidrs, is_unknown
91
+
92
+
93
+ class AwsPublicAdminIngressRule(Rule):
94
+ rule_id = "AWS-NET-001"
95
+ name = "Public SSH/RDP Security Group Ingress"
96
+ description = (
97
+ "Detects security group rules exposing SSH (22) or RDP (3389) to 0.0.0.0/0 or ::/0."
98
+ )
99
+ default_severity = Severity.CRITICAL
100
+
101
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
102
+ if resource.action not in (ActionType.CREATE, ActionType.UPDATE, ActionType.REPLACE):
103
+ return []
104
+
105
+ findings: list[Finding] = []
106
+ after = resource.after or {}
107
+
108
+ # 1. aws_security_group (inline ingress rules)
109
+ if resource.type == "aws_security_group":
110
+ ingress_list = after.get("ingress", [])
111
+ if isinstance(ingress_list, list):
112
+ for idx, ing in enumerate(ingress_list):
113
+ if not isinstance(ing, dict):
114
+ continue
115
+ cidrs, _ = _extract_cidrs(ing)
116
+ public_cidrs = cidrs.intersection(PUBLIC_CIDRS)
117
+ from_p = ing.get("from_port")
118
+ to_p = ing.get("to_port")
119
+ proto = str(ing.get("protocol", "")).lower()
120
+
121
+ matches_admin, port_desc = _ports_include_admin(from_p, to_p, proto)
122
+ if matches_admin and public_cidrs:
123
+ findings.append(
124
+ self._create_finding(
125
+ resource=resource,
126
+ context=context,
127
+ port_desc=port_desc,
128
+ exposed_cidrs=sorted(public_cidrs),
129
+ detail=f"inline ingress rule #{idx}",
130
+ )
131
+ )
132
+
133
+ # 2. aws_security_group_rule
134
+ elif resource.type == "aws_security_group_rule":
135
+ rule_type = str(after.get("type", "")).lower()
136
+ if rule_type == "ingress":
137
+ cidrs, _ = _extract_cidrs(after)
138
+ public_cidrs = cidrs.intersection(PUBLIC_CIDRS)
139
+ from_p = after.get("from_port")
140
+ to_p = after.get("to_port")
141
+ proto = str(after.get("protocol", "")).lower()
142
+
143
+ matches_admin, port_desc = _ports_include_admin(from_p, to_p, proto)
144
+ if matches_admin and public_cidrs:
145
+ findings.append(
146
+ self._create_finding(
147
+ resource=resource,
148
+ context=context,
149
+ port_desc=port_desc,
150
+ exposed_cidrs=sorted(public_cidrs),
151
+ detail="standalone security group ingress rule",
152
+ )
153
+ )
154
+
155
+ # 3. aws_vpc_security_group_ingress_rule
156
+ elif resource.type == "aws_vpc_security_group_ingress_rule":
157
+ cidrs, _ = _extract_cidrs(after)
158
+ public_cidrs = cidrs.intersection(PUBLIC_CIDRS)
159
+ from_p = after.get("from_port")
160
+ to_p = after.get("to_port")
161
+ proto = str(after.get("ip_protocol", "")).lower()
162
+
163
+ matches_admin, port_desc = _ports_include_admin(from_p, to_p, proto)
164
+ if matches_admin and public_cidrs:
165
+ findings.append(
166
+ self._create_finding(
167
+ resource=resource,
168
+ context=context,
169
+ port_desc=port_desc,
170
+ exposed_cidrs=sorted(public_cidrs),
171
+ detail="aws_vpc_security_group_ingress_rule",
172
+ )
173
+ )
174
+
175
+ return findings
176
+
177
+ def _create_finding(
178
+ self,
179
+ resource: ResourceChange,
180
+ context: RuleContext,
181
+ port_desc: str,
182
+ exposed_cidrs: list[str],
183
+ detail: str,
184
+ ) -> Finding:
185
+ base_factors = RiskFactors(
186
+ change_impact=3.5,
187
+ security_exposure=5.0,
188
+ reversibility=1.0,
189
+ uncertainty=0.5,
190
+ )
191
+ resolved_factors, score = score_finding(base_factors, context.environment)
192
+
193
+ return Finding(
194
+ rule_id=self.rule_id,
195
+ rule_name=self.name,
196
+ severity=self.default_severity,
197
+ resource_address=resource.address,
198
+ resource_type=resource.type,
199
+ action=resource.action,
200
+ what_changed=(
201
+ f"Ingress rule on '{resource.address}' exposes {port_desc} to public internet ({', '.join(exposed_cidrs)})."
202
+ ),
203
+ why_it_matters=(
204
+ "Exposing management ports (SSH/RDP) to 0.0.0.0/0 invites brute-force credential stuffing, "
205
+ "automated vulnerability scans, and potential unauthorized system access."
206
+ ),
207
+ remediation=(
208
+ "Restrict ingress CIDRs to a corporate VPN, trusted bastion host, or migrate to "
209
+ "AWS Systems Manager Session Manager for credential-free, portless shell access."
210
+ ),
211
+ risk_factors=resolved_factors,
212
+ calculated_score=score,
213
+ details={
214
+ "exposed_ports": port_desc,
215
+ "exposed_cidrs": exposed_cidrs,
216
+ "rule_context": detail,
217
+ },
218
+ )
219
+
220
+
221
+ class AwsS3PublicAccessBlockRule(Rule):
222
+ rule_id = "AWS-S3-001"
223
+ name = "S3 Public Access Block Disabled or Removed"
224
+ description = (
225
+ "Detects removal, disabling, or permissive changes to S3 bucket public access blocks."
226
+ )
227
+ default_severity = Severity.CRITICAL
228
+
229
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
230
+ findings: list[Finding] = []
231
+
232
+ # 1. aws_s3_bucket_public_access_block deletion or replacement
233
+ if resource.type == "aws_s3_bucket_public_access_block":
234
+ if resource.action in (ActionType.DELETE, ActionType.REPLACE):
235
+ action_text = "deleted" if resource.action == ActionType.DELETE else "recreated"
236
+ base_factors = RiskFactors(
237
+ change_impact=4.0,
238
+ security_exposure=5.0,
239
+ reversibility=2.0,
240
+ uncertainty=0.5,
241
+ )
242
+ resolved_factors, score = score_finding(base_factors, context.environment)
243
+ findings.append(
244
+ Finding(
245
+ rule_id=self.rule_id,
246
+ rule_name=self.name,
247
+ severity=Severity.CRITICAL,
248
+ resource_address=resource.address,
249
+ resource_type=resource.type,
250
+ action=resource.action,
251
+ what_changed=(
252
+ f"S3 Public Access Block configuration '{resource.address}' is being {action_text}."
253
+ ),
254
+ why_it_matters=(
255
+ "Removing the bucket-level Public Access Block allows bucket policies or object ACLs "
256
+ "to make bucket contents publicly accessible to the internet."
257
+ ),
258
+ remediation=(
259
+ "Retain the aws_s3_bucket_public_access_block resource to ensure public access "
260
+ "remains unconditionally blocked."
261
+ ),
262
+ risk_factors=resolved_factors,
263
+ calculated_score=score,
264
+ details={"action": resource.action.value},
265
+ )
266
+ )
267
+
268
+ # Check for block settings set to False
269
+ elif resource.action in (ActionType.CREATE, ActionType.UPDATE):
270
+ after = resource.after or {}
271
+ disabled_flags = [
272
+ flag
273
+ for flag in (
274
+ "block_public_acls",
275
+ "block_public_policy",
276
+ "ignore_public_acls",
277
+ "restrict_public_buckets",
278
+ )
279
+ if after.get(flag) is False
280
+ ]
281
+
282
+ if disabled_flags:
283
+ severity = Severity.CRITICAL if len(disabled_flags) >= 2 else Severity.HIGH
284
+ base_factors = RiskFactors(
285
+ change_impact=3.5,
286
+ security_exposure=4.5 if severity == Severity.HIGH else 5.0,
287
+ reversibility=2.0,
288
+ uncertainty=0.5,
289
+ )
290
+ resolved_factors, score = score_finding(base_factors, context.environment)
291
+ findings.append(
292
+ Finding(
293
+ rule_id=self.rule_id,
294
+ rule_name=self.name,
295
+ severity=severity,
296
+ resource_address=resource.address,
297
+ resource_type=resource.type,
298
+ action=resource.action,
299
+ what_changed=(
300
+ f"S3 Public Access Block '{resource.address}' has protective controls disabled: "
301
+ f"{', '.join(disabled_flags)}."
302
+ ),
303
+ why_it_matters=(
304
+ "Disabling public access block settings leaves the bucket vulnerable to unintended "
305
+ "data exposure through permissive object ACLs or misconfigured bucket policies."
306
+ ),
307
+ remediation=(
308
+ "Set block_public_acls, block_public_policy, ignore_public_acls, and "
309
+ "restrict_public_buckets to true."
310
+ ),
311
+ risk_factors=resolved_factors,
312
+ calculated_score=score,
313
+ details={"disabled_flags": disabled_flags},
314
+ )
315
+ )
316
+
317
+ # 2. aws_s3_bucket with public ACL
318
+ elif resource.type == "aws_s3_bucket":
319
+ after = resource.after or {}
320
+ acl = str(after.get("acl", "")).lower()
321
+ if acl in ("public-read", "public-read-write", "website"):
322
+ base_factors = RiskFactors(
323
+ change_impact=3.5,
324
+ security_exposure=5.0,
325
+ reversibility=1.5,
326
+ uncertainty=0.5,
327
+ )
328
+ resolved_factors, score = score_finding(base_factors, context.environment)
329
+ findings.append(
330
+ Finding(
331
+ rule_id=self.rule_id,
332
+ rule_name=self.name,
333
+ severity=Severity.CRITICAL,
334
+ resource_address=resource.address,
335
+ resource_type=resource.type,
336
+ action=resource.action,
337
+ what_changed=(
338
+ f"S3 bucket '{resource.address}' is configured with public ACL: '{acl}'."
339
+ ),
340
+ why_it_matters=(
341
+ f"Setting ACL to '{acl}' enables unauthenticated public read or write access."
342
+ ),
343
+ remediation=(
344
+ "Change the bucket ACL to 'private' and ensure bucket policies enforce access restrictions."
345
+ ),
346
+ risk_factors=resolved_factors,
347
+ calculated_score=score,
348
+ details={"acl": acl},
349
+ )
350
+ )
351
+
352
+ return findings
353
+
354
+
355
+ class AwsIamWildcardRule(Rule):
356
+ rule_id = "AWS-IAM-001"
357
+ name = "IAM Policy Wildcard Actions or Resources"
358
+ description = "Detects overly permissive IAM policies granting wildcard actions or resources."
359
+ default_severity = Severity.HIGH
360
+
361
+ IAM_POLICY_TYPES = {
362
+ "aws_iam_policy",
363
+ "aws_iam_role_policy",
364
+ "aws_iam_user_policy",
365
+ "aws_iam_group_policy",
366
+ }
367
+
368
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
369
+ if resource.type not in self.IAM_POLICY_TYPES:
370
+ return []
371
+ if resource.action not in (ActionType.CREATE, ActionType.UPDATE, ActionType.REPLACE):
372
+ return []
373
+
374
+ after = resource.after or {}
375
+ policy_raw = after.get("policy")
376
+ if not policy_raw:
377
+ return []
378
+
379
+ doc = self._parse_policy_doc(policy_raw)
380
+ if not doc:
381
+ return []
382
+
383
+ statements = doc.get("Statement", [])
384
+ if isinstance(statements, dict):
385
+ statements = [statements]
386
+ if not isinstance(statements, list):
387
+ return []
388
+
389
+ findings: list[Finding] = []
390
+ for idx, stmt in enumerate(statements):
391
+ if not isinstance(stmt, dict):
392
+ continue
393
+ if str(stmt.get("Effect", "")).lower() != "allow":
394
+ continue
395
+
396
+ actions = self._normalize_list(stmt.get("Action"))
397
+ resources = self._normalize_list(stmt.get("Resource"))
398
+
399
+ # Condition 1: Full Administrator (Action: * and Resource: *)
400
+ if "*" in actions and "*" in resources:
401
+ base_factors = RiskFactors(
402
+ change_impact=4.5,
403
+ security_exposure=5.0,
404
+ reversibility=1.5,
405
+ uncertainty=0.5,
406
+ )
407
+ resolved_factors, score = score_finding(base_factors, context.environment)
408
+ findings.append(
409
+ Finding(
410
+ rule_id=self.rule_id,
411
+ rule_name=self.name,
412
+ severity=Severity.CRITICAL,
413
+ resource_address=resource.address,
414
+ resource_type=resource.type,
415
+ action=resource.action,
416
+ what_changed=(
417
+ f"IAM policy on '{resource.address}' statement #{idx} grants full admin access ('Action': '*', 'Resource': '*')."
418
+ ),
419
+ why_it_matters=(
420
+ "Granting unrestricted '*' on all resources provides full administrative privileges, "
421
+ "bypassing least-privilege boundaries and enabling full account compromise."
422
+ ),
423
+ remediation=(
424
+ "Replace wildcards with explicit service actions and target resource ARNs."
425
+ ),
426
+ risk_factors=resolved_factors,
427
+ calculated_score=score,
428
+ details={
429
+ "statement_index": idx,
430
+ "actions": actions,
431
+ "resources": resources,
432
+ },
433
+ )
434
+ )
435
+
436
+ # Condition 2: Broad action wildcards on Resource: * (e.g., iam:*, s3:*, etc.)
437
+ elif "*" in resources and any("*" in a for a in actions):
438
+ broad_actions = [a for a in actions if "*" in a]
439
+ base_factors = RiskFactors(
440
+ change_impact=3.5,
441
+ security_exposure=4.0,
442
+ reversibility=1.5,
443
+ uncertainty=0.5,
444
+ )
445
+ resolved_factors, score = score_finding(base_factors, context.environment)
446
+ findings.append(
447
+ Finding(
448
+ rule_id=self.rule_id,
449
+ rule_name=self.name,
450
+ severity=Severity.HIGH,
451
+ resource_address=resource.address,
452
+ resource_type=resource.type,
453
+ action=resource.action,
454
+ what_changed=(
455
+ f"IAM policy on '{resource.address}' statement #{idx} grants broad wildcard actions ({', '.join(broad_actions)}) on all resources ('*')."
456
+ ),
457
+ why_it_matters=(
458
+ "Wildcard service actions on all resources allow unintended high-risk operations "
459
+ "(e.g., privilege escalation, data exfiltration, or configuration tampering)."
460
+ ),
461
+ remediation=(
462
+ "Scope IAM actions to specific verbs (e.g., 's3:GetObject' instead of 's3:*') "
463
+ "and restrict resource ARNs."
464
+ ),
465
+ risk_factors=resolved_factors,
466
+ calculated_score=score,
467
+ details={
468
+ "statement_index": idx,
469
+ "broad_actions": broad_actions,
470
+ "resources": resources,
471
+ },
472
+ )
473
+ )
474
+
475
+ # Condition 3: Wildcard action on scoped resource or vice versa
476
+ elif any("*" in a for a in actions):
477
+ wildcard_actions = [a for a in actions if "*" in a]
478
+ base_factors = RiskFactors(
479
+ change_impact=2.5,
480
+ security_exposure=3.0,
481
+ reversibility=1.0,
482
+ uncertainty=0.5,
483
+ )
484
+ resolved_factors, score = score_finding(base_factors, context.environment)
485
+ findings.append(
486
+ Finding(
487
+ rule_id=self.rule_id,
488
+ rule_name=self.name,
489
+ severity=Severity.MODERATE,
490
+ resource_address=resource.address,
491
+ resource_type=resource.type,
492
+ action=resource.action,
493
+ what_changed=(
494
+ f"IAM policy on '{resource.address}' statement #{idx} contains wildcard actions: {', '.join(wildcard_actions)}."
495
+ ),
496
+ why_it_matters=(
497
+ "Wildcard actions may grant new permissions unintentionally as AWS adds API actions to the service."
498
+ ),
499
+ remediation=(
500
+ "Explicitly enumerate required IAM actions instead of using prefix wildcards."
501
+ ),
502
+ risk_factors=resolved_factors,
503
+ calculated_score=score,
504
+ details={"statement_index": idx, "wildcard_actions": wildcard_actions},
505
+ )
506
+ )
507
+
508
+ return findings
509
+
510
+ def _parse_policy_doc(self, policy: Any) -> dict[str, Any] | None:
511
+ if isinstance(policy, dict):
512
+ return policy
513
+ if isinstance(policy, str):
514
+ try:
515
+ data = json.loads(policy)
516
+ return data if isinstance(data, dict) else None
517
+ except json.JSONDecodeError:
518
+ return None
519
+ return None
520
+
521
+ def _normalize_list(self, value: Any) -> list[str]:
522
+ if isinstance(value, str):
523
+ return [value]
524
+ if isinstance(value, list):
525
+ return [str(v) for v in value]
526
+ return []
527
+
528
+
529
+ class AwsDatabaseDeletionReplacementRule(Rule):
530
+ rule_id = "AWS-DB-001"
531
+ name = "AWS Database Deletion or Replacement"
532
+ description = "Detects destructive deletion or replacement of AWS database resources."
533
+ default_severity = Severity.CRITICAL
534
+
535
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
536
+ if resource.type not in AWS_DATABASE_TYPES:
537
+ return []
538
+ if resource.action not in (ActionType.DELETE, ActionType.REPLACE):
539
+ return []
540
+
541
+ action_desc = (
542
+ "replacement (recreation)" if resource.action == ActionType.REPLACE else "deletion"
543
+ )
544
+
545
+ base_factors = RiskFactors(
546
+ change_impact=5.0,
547
+ security_exposure=1.0,
548
+ reversibility=5.0,
549
+ uncertainty=1.0,
550
+ )
551
+ resolved_factors, score = score_finding(base_factors, context.environment)
552
+
553
+ return [
554
+ Finding(
555
+ rule_id=self.rule_id,
556
+ rule_name=self.name,
557
+ severity=self.default_severity,
558
+ resource_address=resource.address,
559
+ resource_type=resource.type,
560
+ action=resource.action,
561
+ what_changed=(
562
+ f"AWS database resource '{resource.address}' ({resource.type}) is scheduled for {action_desc}."
563
+ ),
564
+ why_it_matters=(
565
+ "Destroying or replacing a managed database causes service outage and permanent data loss "
566
+ "unless an automated point-in-time snapshot or replication cluster is ready to take over."
567
+ ),
568
+ remediation=(
569
+ "Verify deletion_protection is active, ensure a recent final snapshot exists, and inspect "
570
+ "'replace_paths' to determine whether the change can be applied in-place without recreation."
571
+ ),
572
+ risk_factors=resolved_factors,
573
+ calculated_score=score,
574
+ details={
575
+ "database_type": resource.type,
576
+ "action": resource.action.value,
577
+ "replace_paths": [str(p) for p in (resource.replace_paths or [])],
578
+ },
579
+ )
580
+ ]