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.
@@ -0,0 +1,298 @@
1
+ """Provider-neutral detection rules for Terraform plans."""
2
+
3
+ from preflight.models import (
4
+ ActionType,
5
+ Finding,
6
+ ResourceChange,
7
+ RiskFactors,
8
+ Severity,
9
+ )
10
+ from preflight.rules.base import Rule, RuleContext
11
+ from preflight.scoring import score_finding
12
+
13
+ # Types typically representing stateful or persistent resources where forced recreation is high-risk
14
+ STATEFUL_PATTERNS = (
15
+ "db",
16
+ "database",
17
+ "cluster",
18
+ "instance",
19
+ "volume",
20
+ "disk",
21
+ "bucket",
22
+ "table",
23
+ "storage",
24
+ "queue",
25
+ "topic",
26
+ )
27
+
28
+
29
+ class GenericResourceDeletionRule(Rule):
30
+ rule_id = "GEN-DEL-001"
31
+ name = "Resource Deletion"
32
+ description = "Detects resource deletion in the Terraform plan."
33
+ default_severity = Severity.HIGH
34
+
35
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
36
+ if resource.action != ActionType.DELETE:
37
+ return []
38
+
39
+ base_factors = RiskFactors(
40
+ change_impact=4.0,
41
+ security_exposure=1.0,
42
+ reversibility=4.5,
43
+ uncertainty=0.0,
44
+ )
45
+ resolved_factors, score = score_finding(base_factors, context.environment)
46
+
47
+ return [
48
+ Finding(
49
+ rule_id=self.rule_id,
50
+ rule_name=self.name,
51
+ severity=self.default_severity,
52
+ resource_address=resource.address,
53
+ resource_type=resource.type,
54
+ action=resource.action,
55
+ what_changed=f"Resource '{resource.address}' ({resource.type}) is scheduled for permanent deletion.",
56
+ why_it_matters=(
57
+ "Deleting infrastructure resources destroys active configuration, "
58
+ "removes state, and can sever dependencies for other services."
59
+ ),
60
+ remediation=(
61
+ "Verify this deletion is intended. Ensure dependent workloads have been migrated "
62
+ "or decommissioned before applying the change."
63
+ ),
64
+ risk_factors=resolved_factors,
65
+ calculated_score=score,
66
+ details={"raw_actions": resource.raw_actions},
67
+ )
68
+ ]
69
+
70
+
71
+ class GenericResourceReplacementRule(Rule):
72
+ rule_id = "GEN-REP-001"
73
+ name = "Resource Replacement"
74
+ description = "Detects resource recreation caused by immutable attribute modifications."
75
+ default_severity = Severity.HIGH
76
+
77
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
78
+ if resource.action != ActionType.REPLACE:
79
+ return []
80
+
81
+ replace_paths = [str(p) for p in (resource.replace_paths or [])]
82
+ paths_desc = f" due to changes in: {', '.join(replace_paths)}" if replace_paths else ""
83
+
84
+ base_factors = RiskFactors(
85
+ change_impact=4.0,
86
+ security_exposure=2.0,
87
+ reversibility=4.0,
88
+ uncertainty=1.0,
89
+ )
90
+ resolved_factors, score = score_finding(base_factors, context.environment)
91
+
92
+ return [
93
+ Finding(
94
+ rule_id=self.rule_id,
95
+ rule_name=self.name,
96
+ severity=self.default_severity,
97
+ resource_address=resource.address,
98
+ resource_type=resource.type,
99
+ action=resource.action,
100
+ what_changed=(
101
+ f"Resource '{resource.address}' ({resource.type}) will be destroyed and recreated{paths_desc}."
102
+ ),
103
+ why_it_matters=(
104
+ "Resource replacement involves destroying the existing instance before or after creating a new one. "
105
+ "This causes IP/endpoint changes, state resets, and temporary downtime."
106
+ ),
107
+ remediation=(
108
+ "Review the attributes triggering replacement in 'replace_paths'. Consider if changes can be performed "
109
+ "with zero-downtime blue/green patterns or by updating mutable fields in-place."
110
+ ),
111
+ risk_factors=resolved_factors,
112
+ calculated_score=score,
113
+ details={"replace_paths": replace_paths, "raw_actions": resource.raw_actions},
114
+ )
115
+ ]
116
+
117
+
118
+ class GenericUnknownActionRule(Rule):
119
+ rule_id = "GEN-ACT-001"
120
+ name = "Unknown Change Action"
121
+ description = "Detects unsupported or unrecognized action sequences in plan."
122
+ default_severity = Severity.HIGH
123
+
124
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
125
+ if resource.action != ActionType.UNKNOWN:
126
+ return []
127
+
128
+ base_factors = RiskFactors(
129
+ change_impact=3.0,
130
+ security_exposure=2.0,
131
+ reversibility=3.0,
132
+ uncertainty=5.0,
133
+ )
134
+ resolved_factors, score = score_finding(base_factors, context.environment)
135
+
136
+ return [
137
+ Finding(
138
+ rule_id=self.rule_id,
139
+ rule_name=self.name,
140
+ severity=self.default_severity,
141
+ resource_address=resource.address,
142
+ resource_type=resource.type,
143
+ action=resource.action,
144
+ what_changed=(
145
+ f"Resource '{resource.address}' contains unrecognized action sequence: {resource.raw_actions}."
146
+ ),
147
+ why_it_matters=(
148
+ "Preflight cannot deterministically predict the lifecycle behavior of unrecognized Terraform actions."
149
+ ),
150
+ remediation=(
151
+ "Inspect the plan manually using 'terraform show' to confirm the exact operation being performed."
152
+ ),
153
+ risk_factors=resolved_factors,
154
+ calculated_score=score,
155
+ details={"raw_actions": resource.raw_actions},
156
+ )
157
+ ]
158
+
159
+
160
+ class GenericUnknownValuesRule(Rule):
161
+ rule_id = "GEN-UNK-001"
162
+ name = "Unknown Computed Values"
163
+ description = "Detects unknown values that hinder reliable pre-deployment risk assessment."
164
+ default_severity = Severity.MODERATE
165
+
166
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
167
+ # Only evaluate on resources being created or updated
168
+ if resource.action not in (ActionType.CREATE, ActionType.UPDATE, ActionType.REPLACE):
169
+ return []
170
+
171
+ if not resource.after_unknown:
172
+ return []
173
+
174
+ # Filter out standard read-only computed attributes generated by Terraform
175
+ BENIGN_COMPUTED = {
176
+ "id",
177
+ "arn",
178
+ "created_at",
179
+ "updated_at",
180
+ "version_id",
181
+ "tags_all",
182
+ "owner_id",
183
+ }
184
+ unknown_keys = [
185
+ k
186
+ for k, v in resource.after_unknown.items()
187
+ if (
188
+ v is True
189
+ or (isinstance(v, dict) and any(v.values()))
190
+ or (isinstance(v, list) and len(v) > 0)
191
+ )
192
+ and k not in BENIGN_COMPUTED
193
+ ]
194
+
195
+ if not unknown_keys:
196
+ return []
197
+
198
+ # If after is extensively unknown
199
+ is_extensively_unknown = len(unknown_keys) > 5
200
+
201
+ base_factors = RiskFactors(
202
+ change_impact=2.0 if is_extensively_unknown else 1.0,
203
+ security_exposure=2.0 if is_extensively_unknown else 1.0,
204
+ reversibility=1.0,
205
+ uncertainty=4.5 if is_extensively_unknown else 3.0,
206
+ )
207
+ resolved_factors, score = score_finding(base_factors, context.environment)
208
+
209
+ return [
210
+ Finding(
211
+ rule_id=self.rule_id,
212
+ rule_name=self.name,
213
+ severity=self.default_severity,
214
+ resource_address=resource.address,
215
+ resource_type=resource.type,
216
+ action=resource.action,
217
+ what_changed=(
218
+ f"Resource '{resource.address}' has {len(unknown_keys)} attribute(s) computed at apply time: "
219
+ f"{', '.join(unknown_keys[:5])}{'...' if len(unknown_keys) > 5 else ''}."
220
+ ),
221
+ why_it_matters=(
222
+ "Unknown computed values cannot be statically verified prior to deployment. "
223
+ "Rules inspecting these fields may not identify exposure until after application."
224
+ ),
225
+ remediation=(
226
+ "Where possible, provide explicit values rather than depending on dynamic interpolation "
227
+ "for sensitive security or routing parameters."
228
+ ),
229
+ risk_factors=resolved_factors,
230
+ calculated_score=score,
231
+ details={"unknown_attributes": unknown_keys[:10]},
232
+ )
233
+ ]
234
+
235
+
236
+ class GenericDestructiveChangeRule(Rule):
237
+ rule_id = "GEN-DST-001"
238
+ name = "Destructive Stateful Resource Change"
239
+ description = "Detects destructive changes to stateful storage, compute, or database resources."
240
+ default_severity = Severity.HIGH
241
+
242
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
243
+ if resource.action not in (ActionType.DELETE, ActionType.REPLACE):
244
+ return []
245
+
246
+ type_lower = resource.type.lower()
247
+ NON_STATEFUL_SUFFIXES = (
248
+ "_policy",
249
+ "_rule",
250
+ "_attachment",
251
+ "_association",
252
+ "_public_access_block",
253
+ "_metric",
254
+ "_alarm",
255
+ )
256
+ if any(type_lower.endswith(suffix) for suffix in NON_STATEFUL_SUFFIXES):
257
+ return []
258
+
259
+ is_stateful = any(pattern in type_lower for pattern in STATEFUL_PATTERNS)
260
+ if not is_stateful:
261
+ return []
262
+
263
+ base_factors = RiskFactors(
264
+ change_impact=4.5,
265
+ security_exposure=1.5,
266
+ reversibility=4.5,
267
+ uncertainty=1.0,
268
+ )
269
+ resolved_factors, score = score_finding(base_factors, context.environment)
270
+
271
+ action_desc = (
272
+ "replacement (recreation)" if resource.action == ActionType.REPLACE else "deletion"
273
+ )
274
+
275
+ return [
276
+ Finding(
277
+ rule_id=self.rule_id,
278
+ rule_name=self.name,
279
+ severity=self.default_severity,
280
+ resource_address=resource.address,
281
+ resource_type=resource.type,
282
+ action=resource.action,
283
+ what_changed=(
284
+ f"Stateful resource '{resource.address}' ({resource.type}) is scheduled for {action_desc}."
285
+ ),
286
+ why_it_matters=(
287
+ "Stateful resources store persistent data or maintain state. Destroying or recreating them "
288
+ "risks unrecoverable data loss and prolonged downtime."
289
+ ),
290
+ remediation=(
291
+ "Verify that data snapshots exist, ensure deletion protection settings are reviewed, "
292
+ "and double-check whether replacement can be avoided."
293
+ ),
294
+ risk_factors=resolved_factors,
295
+ calculated_score=score,
296
+ details={"stateful_type": resource.type, "action": resource.action.value},
297
+ )
298
+ ]
@@ -0,0 +1,264 @@
1
+ """K8S specific detection rules"""
2
+
3
+ from typing import Any
4
+
5
+ from preflight.models import (
6
+ ActionType,
7
+ Finding,
8
+ ResourceChange,
9
+ RiskFactors,
10
+ Severity,
11
+ )
12
+ from preflight.rules.base import Rule, RuleContext
13
+ from preflight.scoring import score_finding
14
+
15
+ K8S_WORKLOAD_TYPES = {
16
+ "kubernetes_deployment",
17
+ "kubernetes_deployment_v1",
18
+ "kubernetes_daemonset",
19
+ "kubernetes_daemon_set",
20
+ "kubernetes_daemon_set_v1",
21
+ "kubernetes_stateful_set",
22
+ "kubernetes_stateful_set_v1",
23
+ "kubernetes_job",
24
+ "kubernetes_job_v1",
25
+ "kubernetes_cron_job",
26
+ "kubernetes_cron_job_v1",
27
+ "kubernetes_pod",
28
+ "kubernetes_pod_v1",
29
+ "kubernetes_replication_controller",
30
+ "kubernetes_replication_controller_v1",
31
+ }
32
+
33
+ K8S_BINDING_TYPES = {
34
+ "kubernetes_cluster_role_binding",
35
+ "kubernetes_cluster_role_binding_v1",
36
+ "kubernetes_role_binding",
37
+ "kubernetes_role_binding_v1",
38
+ }
39
+
40
+
41
+ def _is_truthy(val: Any) -> bool:
42
+ if isinstance(val, bool):
43
+ return val
44
+ if isinstance(val, str):
45
+ return val.strip().lower() in ("true", "1", "yes")
46
+ return False
47
+
48
+
49
+ def _check_container_list(containers: Any) -> list[str]:
50
+ violations: list[str] = []
51
+ if isinstance(containers, dict):
52
+ containers = [containers]
53
+ if not isinstance(containers, list):
54
+ return violations
55
+
56
+ for c in containers:
57
+ if not isinstance(c, dict):
58
+ continue
59
+ c_name = c.get("name") or "unnamed"
60
+ sec_ctx = c.get("security_context") or c.get("securityContext")
61
+ if isinstance(sec_ctx, list) and sec_ctx:
62
+ sec_ctx = sec_ctx[0]
63
+ if isinstance(sec_ctx, dict) and _is_truthy(sec_ctx.get("privileged")):
64
+ violations.append(f"container '{c_name}' has privileged=true")
65
+
66
+ return violations
67
+
68
+
69
+ def _find_k8s_workload_violations(data: Any) -> list[str]:
70
+ violations: list[str] = []
71
+
72
+ def _traverse(node: Any) -> None:
73
+ if isinstance(node, dict):
74
+ if _is_truthy(node.get("host_network")) or _is_truthy(node.get("hostNetwork")):
75
+ violations.append("hostNetwork is enabled")
76
+ if _is_truthy(node.get("host_pid")) or _is_truthy(node.get("hostPID")):
77
+ violations.append("hostPID is enabled")
78
+ if _is_truthy(node.get("host_ipc")) or _is_truthy(node.get("hostIPC")):
79
+ violations.append("hostIPC is enabled")
80
+
81
+ for key in ("container", "containers", "init_container", "init_containers"):
82
+ if key in node:
83
+ violations.extend(_check_container_list(node[key]))
84
+
85
+ for v in node.values():
86
+ if isinstance(v, (dict, list)):
87
+ _traverse(v)
88
+ elif isinstance(node, list):
89
+ for item in node:
90
+ if isinstance(item, (dict, list)):
91
+ _traverse(item)
92
+
93
+ _traverse(data)
94
+
95
+ seen: set[str] = set()
96
+ deduped: list[str] = []
97
+ for v in violations:
98
+ if v not in seen:
99
+ seen.add(v)
100
+ deduped.append(v)
101
+ return deduped
102
+
103
+
104
+ def _check_helm_release(after: dict[str, Any]) -> list[str]:
105
+ violations: list[str] = []
106
+ set_blocks = after.get("set") or []
107
+ if isinstance(set_blocks, list):
108
+ for item in set_blocks:
109
+ if not isinstance(item, dict):
110
+ continue
111
+ name = str(item.get("name", "")).lower()
112
+ val = item.get("value")
113
+ if not _is_truthy(val):
114
+ continue
115
+ if "privileged" in name:
116
+ violations.append(f"helm value '{item.get('name')}' enables privileged mode")
117
+ elif "hostnetwork" in name:
118
+ violations.append(f"helm value '{item.get('name')}' enables hostNetwork")
119
+
120
+ values_raw = after.get("values")
121
+ if isinstance(values_raw, list):
122
+ for raw in values_raw:
123
+ if not isinstance(raw, str):
124
+ continue
125
+ raw_lower = raw.lower()
126
+ if "privileged: true" in raw_lower:
127
+ violations.append("helm values contains privileged: true")
128
+ if "hostnetwork: true" in raw_lower:
129
+ violations.append("helm values contains hostNetwork: true")
130
+
131
+ return violations
132
+
133
+
134
+ class KubernetesWorkloadSecurityRule(Rule):
135
+ rule_id = "K8S-SEC-001"
136
+ name = "Kubernetes Workload Privileged or Host Access"
137
+ description = "Detects Kubernetes pods and workloads running in privileged mode or with host network/PID/IPC namespaces."
138
+ default_severity = Severity.HIGH
139
+
140
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
141
+ if resource.action == ActionType.DELETE or not resource.after:
142
+ return []
143
+
144
+ violations: list[str] = []
145
+
146
+ if resource.type in K8S_WORKLOAD_TYPES or resource.type.startswith("kubernetes_"):
147
+ violations.extend(_find_k8s_workload_violations(resource.after))
148
+ elif resource.type == "helm_release":
149
+ violations.extend(_check_helm_release(resource.after))
150
+
151
+ if not violations:
152
+ return []
153
+
154
+ base_factors = RiskFactors(
155
+ change_impact=3.5,
156
+ security_exposure=5.0,
157
+ reversibility=1.0,
158
+ uncertainty=0.5,
159
+ )
160
+ resolved_factors, score = score_finding(base_factors, context.environment)
161
+
162
+ return [
163
+ Finding(
164
+ rule_id=self.rule_id,
165
+ rule_name=self.name,
166
+ severity=self.default_severity,
167
+ resource_address=resource.address,
168
+ resource_type=resource.type,
169
+ action=resource.action,
170
+ what_changed=(
171
+ f"Kubernetes workload '{resource.address}' enables privileged or host access: "
172
+ f"{'; '.join(violations)}."
173
+ ),
174
+ why_it_matters=(
175
+ "Privileged containers and host namespace sharing compromise node boundary isolation, "
176
+ "enabling container breakout, host filesystem tampering, and cluster-wide node takeover."
177
+ ),
178
+ remediation=(
179
+ "Disable privileged container mode (privileged = false) and avoid hostNetwork, hostPID, "
180
+ "or hostIPC unless strictly required by node-level daemon plugins."
181
+ ),
182
+ risk_factors=resolved_factors,
183
+ calculated_score=score,
184
+ details={"violations": violations},
185
+ )
186
+ ]
187
+
188
+
189
+ class KubernetesClusterAdminBindingRule(Rule):
190
+ rule_id = "K8S-RBAC-001"
191
+ name = "Kubernetes ClusterRoleBinding to cluster-admin"
192
+ description = (
193
+ "Detects RoleBindings and ClusterRoleBindings granting superuser cluster-admin privileges."
194
+ )
195
+ default_severity = Severity.CRITICAL
196
+
197
+ def evaluate(self, resource: ResourceChange, context: RuleContext) -> list[Finding]:
198
+ if resource.action == ActionType.DELETE or not resource.after:
199
+ return []
200
+
201
+ if resource.type not in K8S_BINDING_TYPES:
202
+ return []
203
+
204
+ role_ref = resource.after.get("role_ref")
205
+ if isinstance(role_ref, list) and role_ref:
206
+ role_ref = role_ref[0]
207
+
208
+ if not isinstance(role_ref, dict):
209
+ return []
210
+
211
+ role_name = str(role_ref.get("name", "")).strip()
212
+ if role_name.lower() != "cluster-admin":
213
+ return []
214
+
215
+ subjects = resource.after.get("subject") or resource.after.get("subjects") or []
216
+ if isinstance(subjects, dict):
217
+ subjects = [subjects]
218
+ subject_descs: list[str] = []
219
+ if isinstance(subjects, list):
220
+ for s in subjects:
221
+ if isinstance(s, dict):
222
+ s_kind = s.get("kind", "Subject")
223
+ s_name = s.get("name", "unnamed")
224
+ s_ns = s.get("namespace")
225
+ if s_ns:
226
+ subject_descs.append(f"{s_kind}:{s_ns}/{s_name}")
227
+ else:
228
+ subject_descs.append(f"{s_kind}:{s_name}")
229
+
230
+ subjects_str = ", ".join(subject_descs) if subject_descs else "unspecified subjects"
231
+
232
+ base_factors = RiskFactors(
233
+ change_impact=4.0,
234
+ security_exposure=5.0,
235
+ reversibility=2.0,
236
+ uncertainty=0.5,
237
+ )
238
+ resolved_factors, score = score_finding(base_factors, context.environment)
239
+
240
+ return [
241
+ Finding(
242
+ rule_id=self.rule_id,
243
+ rule_name=self.name,
244
+ severity=self.default_severity,
245
+ resource_address=resource.address,
246
+ resource_type=resource.type,
247
+ action=resource.action,
248
+ what_changed=(
249
+ f"RBAC binding '{resource.address}' binds subject(s) [{subjects_str}] "
250
+ f"to superuser role 'cluster-admin'."
251
+ ),
252
+ why_it_matters=(
253
+ "The cluster-admin role grants unconditional control over every API resource across all "
254
+ "namespaces in the cluster. Misconfigurations allow lateral movement and full cluster compromise."
255
+ ),
256
+ remediation=(
257
+ "Define least-privilege Roles or ClusterRoles granting only the specific API groups, "
258
+ "resources, and verbs needed by the workload or user."
259
+ ),
260
+ risk_factors=resolved_factors,
261
+ calculated_score=score,
262
+ details={"role": role_name, "subjects": subject_descs},
263
+ )
264
+ ]
preflight/scoring.py ADDED
@@ -0,0 +1,140 @@
1
+ """Explainable risk scoring engine for Terraform infrastructure changes."""
2
+
3
+ from preflight.models import (
4
+ Environment,
5
+ Finding,
6
+ ResourceChange,
7
+ ResourceRisk,
8
+ RiskCategory,
9
+ RiskFactors,
10
+ )
11
+
12
+ # Factor weights (sum to 100)
13
+ WEIGHT_CHANGE_IMPACT = 30.0
14
+ WEIGHT_SECURITY_EXPOSURE = 25.0
15
+ WEIGHT_REVERSIBILITY = 20.0
16
+ WEIGHT_ENVIRONMENT_CRITICALITY = 15.0
17
+ WEIGHT_UNCERTAINTY = 10.0
18
+
19
+ # Environment criticality mappings (0 to 5)
20
+ ENV_CRITICALITY_SCORES: dict[Environment, float] = {
21
+ Environment.DEVELOPMENT: 1.0,
22
+ Environment.STAGING: 3.0,
23
+ Environment.PRODUCTION: 5.0,
24
+ }
25
+
26
+
27
+ def calculate_factor_score(
28
+ change_impact: float,
29
+ security_exposure: float,
30
+ reversibility: float,
31
+ environment_criticality: float,
32
+ uncertainty: float,
33
+ ) -> float:
34
+ """Calculate normalized risk score (0 to 100) from 5 factors scored 0 to 5.
35
+
36
+ Formula: Σ(weight * factor_score / 5.0)
37
+ """
38
+ ci = max(0.0, min(5.0, change_impact))
39
+ se = max(0.0, min(5.0, security_exposure))
40
+ rev = max(0.0, min(5.0, reversibility))
41
+ ec = max(0.0, min(5.0, environment_criticality))
42
+ unc = max(0.0, min(5.0, uncertainty))
43
+
44
+ score = (
45
+ (WEIGHT_CHANGE_IMPACT * ci / 5.0)
46
+ + (WEIGHT_SECURITY_EXPOSURE * se / 5.0)
47
+ + (WEIGHT_REVERSIBILITY * rev / 5.0)
48
+ + (WEIGHT_ENVIRONMENT_CRITICALITY * ec / 5.0)
49
+ + (WEIGHT_UNCERTAINTY * unc / 5.0)
50
+ )
51
+ return round(score, 1)
52
+
53
+
54
+ def score_finding(
55
+ factors: RiskFactors,
56
+ environment: Environment,
57
+ ) -> tuple[RiskFactors, float]:
58
+ """Calculate the score for a single finding with environment context."""
59
+ env_crit = ENV_CRITICALITY_SCORES.get(environment, 5.0)
60
+ resolved_factors = RiskFactors(
61
+ change_impact=factors.change_impact,
62
+ security_exposure=factors.security_exposure,
63
+ reversibility=factors.reversibility,
64
+ environment_criticality=env_crit,
65
+ uncertainty=factors.uncertainty,
66
+ )
67
+ score = calculate_factor_score(
68
+ change_impact=resolved_factors.change_impact,
69
+ security_exposure=resolved_factors.security_exposure,
70
+ reversibility=resolved_factors.reversibility,
71
+ environment_criticality=resolved_factors.environment_criticality,
72
+ uncertainty=resolved_factors.uncertainty,
73
+ )
74
+ return resolved_factors, score
75
+
76
+
77
+ def aggregate_resource_risks(
78
+ resources: list[ResourceChange],
79
+ findings: list[Finding],
80
+ environment: Environment,
81
+ ) -> tuple[list[ResourceRisk], float, RiskCategory, str | None]:
82
+ """Aggregate findings per resource and compute resource and plan-level risk.
83
+
84
+ The initial release assigns the maximum resource-level risk score as the
85
+ overall plan risk score.
86
+ """
87
+ findings_by_resource: dict[str, list[Finding]] = {}
88
+ for finding in findings:
89
+ findings_by_resource.setdefault(finding.resource_address, []).append(finding)
90
+
91
+ env_crit = ENV_CRITICALITY_SCORES.get(environment, 5.0)
92
+ resource_risks: list[ResourceRisk] = []
93
+ max_score = 0.0
94
+ highest_contributing_resource: str | None = None
95
+
96
+ for res in resources:
97
+ res_findings = findings_by_resource.get(res.address, [])
98
+ if res_findings:
99
+ # Resource risk factors take the maximum component across its findings
100
+ max_ci = max(f.risk_factors.change_impact for f in res_findings)
101
+ max_se = max(f.risk_factors.security_exposure for f in res_findings)
102
+ max_rev = max(f.risk_factors.reversibility for f in res_findings)
103
+ max_unc = max(f.risk_factors.uncertainty for f in res_findings)
104
+
105
+ res_factors = RiskFactors(
106
+ change_impact=max_ci,
107
+ security_exposure=max_se,
108
+ reversibility=max_rev,
109
+ environment_criticality=env_crit,
110
+ uncertainty=max_unc,
111
+ )
112
+ res_score = calculate_factor_score(max_ci, max_se, max_rev, env_crit, max_unc)
113
+ else:
114
+ res_factors = RiskFactors(
115
+ change_impact=0.0,
116
+ security_exposure=0.0,
117
+ reversibility=0.0,
118
+ environment_criticality=env_crit,
119
+ uncertainty=0.0,
120
+ )
121
+ res_score = 0.0
122
+
123
+ res_category = RiskCategory.from_score(res_score)
124
+ resource_risk = ResourceRisk(
125
+ resource_address=res.address,
126
+ resource_type=res.type,
127
+ action=res.action,
128
+ risk_score=res_score,
129
+ risk_category=res_category,
130
+ risk_factors=res_factors,
131
+ findings=res_findings,
132
+ )
133
+ resource_risks.append(resource_risk)
134
+
135
+ if res_score > max_score:
136
+ max_score = res_score
137
+ highest_contributing_resource = res.address
138
+
139
+ overall_category = RiskCategory.from_score(max_score)
140
+ return resource_risks, max_score, overall_category, highest_contributing_resource