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/__init__.py +3 -0
- preflight/cli.py +536 -0
- preflight/config.py +107 -0
- preflight/engine.py +115 -0
- preflight/github.py +124 -0
- preflight/models.py +274 -0
- preflight/plan.py +165 -0
- preflight/policy.py +67 -0
- preflight/redaction.py +54 -0
- preflight/reporting.py +404 -0
- preflight/rules/__init__.py +85 -0
- preflight/rules/aws.py +580 -0
- preflight/rules/azure.py +309 -0
- preflight/rules/base.py +24 -0
- preflight/rules/gcp.py +250 -0
- preflight/rules/generic.py +298 -0
- preflight/rules/kubernetes.py +264 -0
- preflight/scoring.py +140 -0
- preflight_engine-0.1.0.dist-info/METADATA +271 -0
- preflight_engine-0.1.0.dist-info/RECORD +23 -0
- preflight_engine-0.1.0.dist-info/WHEEL +4 -0
- preflight_engine-0.1.0.dist-info/entry_points.txt +2 -0
- preflight_engine-0.1.0.dist-info/licenses/LICENSE +175 -0
|
@@ -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
|