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/engine.py ADDED
@@ -0,0 +1,115 @@
1
+ """Analysis engine: Coordinates parsing, rule evaluation, scoring, and policy enforcement."""
2
+
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+
6
+ from preflight.models import (
7
+ AnalysisResult,
8
+ Environment,
9
+ Finding,
10
+ PlanSummary,
11
+ PolicyEvaluation,
12
+ ResourceChange,
13
+ )
14
+ from preflight.plan import parse_plan_file, parse_plan_json
15
+ from preflight.policy import PolicyEvaluator
16
+ from preflight.redaction import redact_sensitive_data
17
+ from preflight.rules import Rule, get_default_rules
18
+ from preflight.rules.base import RuleContext
19
+ from preflight.scoring import aggregate_resource_risks
20
+
21
+
22
+ class PreflightEngine:
23
+ """Core analysis engine orchestrating infrastructure change evaluation."""
24
+
25
+ def __init__(self, rules: list[Rule] | None = None) -> None:
26
+ self.rules = rules if rules is not None else get_default_rules()
27
+
28
+ def analyze_file(
29
+ self,
30
+ plan_path: str | Path,
31
+ environment: Environment = Environment.PRODUCTION,
32
+ policy_evaluator: PolicyEvaluator | None = None,
33
+ ) -> AnalysisResult:
34
+ """Run analysis directly on a plan file."""
35
+ resource_changes, summary = parse_plan_file(plan_path)
36
+ identifier = "<stdin>" if str(plan_path) == "-" else str(plan_path)
37
+ return self._run_analysis(
38
+ plan_path=identifier,
39
+ resource_changes=resource_changes,
40
+ summary=summary,
41
+ environment=environment,
42
+ policy_evaluator=policy_evaluator,
43
+ )
44
+
45
+ def analyze_json(
46
+ self,
47
+ json_content: str,
48
+ plan_identifier: str = "<stdin>",
49
+ environment: Environment = Environment.PRODUCTION,
50
+ policy_evaluator: PolicyEvaluator | None = None,
51
+ ) -> AnalysisResult:
52
+ """Run analysis on an in-memory JSON string."""
53
+ resource_changes, summary = parse_plan_json(json_content)
54
+ return self._run_analysis(
55
+ plan_path=plan_identifier,
56
+ resource_changes=resource_changes,
57
+ summary=summary,
58
+ environment=environment,
59
+ policy_evaluator=policy_evaluator,
60
+ )
61
+
62
+ def _run_analysis(
63
+ self,
64
+ plan_path: str,
65
+ resource_changes: list[ResourceChange],
66
+ summary: PlanSummary,
67
+ environment: Environment,
68
+ policy_evaluator: PolicyEvaluator | None,
69
+ ) -> AnalysisResult:
70
+ context = RuleContext(environment=environment)
71
+ all_findings: list[Finding] = []
72
+
73
+ # Evaluate rules against each resource change
74
+ for resource in resource_changes:
75
+ for rule in self.rules:
76
+ findings = rule.evaluate(resource, context)
77
+ for f in findings:
78
+ # Sanitize details using defensive redaction
79
+ f.details = redact_sensitive_data(f.details)
80
+ all_findings.append(f)
81
+
82
+ # Sort findings deterministically (by severity descending, score descending, address ascending, rule ID ascending)
83
+ all_findings.sort(
84
+ key=lambda x: (-x.severity.level, -x.calculated_score, x.resource_address, x.rule_id)
85
+ )
86
+
87
+ # Score and aggregate risks
88
+ resources, overall_score, overall_category, highest_res = aggregate_resource_risks(
89
+ resources=resource_changes,
90
+ findings=all_findings,
91
+ environment=environment,
92
+ )
93
+
94
+ # Evaluate deployment policy
95
+ if policy_evaluator is None:
96
+ policy_evaluator = PolicyEvaluator()
97
+ policy_result: PolicyEvaluation = policy_evaluator.evaluate(
98
+ findings=all_findings,
99
+ overall_risk_score=overall_score,
100
+ )
101
+
102
+ timestamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
103
+
104
+ return AnalysisResult(
105
+ plan_path=plan_path,
106
+ environment=environment,
107
+ summary=summary,
108
+ resources=resources,
109
+ findings=all_findings,
110
+ overall_risk_score=overall_score,
111
+ overall_risk_category=overall_category,
112
+ highest_contributing_resource=highest_res,
113
+ policy_evaluation=policy_result,
114
+ timestamp=timestamp,
115
+ )
preflight/github.py ADDED
@@ -0,0 +1,124 @@
1
+ """GitHub PR comment integration using the GitHub REST API."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+ from preflight import __version__
11
+ from preflight.reporting import PR_COMMENT_MARKER
12
+
13
+
14
+ class GitHubApiError(Exception):
15
+ """Raised when GitHub API operations fail."""
16
+
17
+
18
+ def parse_pr_number_from_ref(ref: str) -> int | None:
19
+ """Extract PR number from refs like 'refs/pull/123/merge' or '123/merge'."""
20
+ match = re.search(r"pull/(\d+)/", ref)
21
+ if match:
22
+ return int(match.group(1))
23
+ match = re.match(r"^(\d+)$", ref.strip())
24
+ if match:
25
+ return int(match.group(1))
26
+ return None
27
+
28
+
29
+ def resolve_github_context(
30
+ repo: str | None = None,
31
+ pr: int | None = None,
32
+ token: str | None = None,
33
+ ) -> tuple[str, int, str]:
34
+ """Resolve repository, pull request number, and token from options or environment."""
35
+ resolved_repo = repo or os.environ.get("GITHUB_REPOSITORY")
36
+ if not resolved_repo or "/" not in resolved_repo:
37
+ raise GitHubApiError(
38
+ "GitHub repository not specified. Pass '--repo owner/repo' or set GITHUB_REPOSITORY."
39
+ )
40
+
41
+ resolved_pr = pr
42
+ if resolved_pr is None:
43
+ pr_env = os.environ.get("PR_NUMBER")
44
+ if pr_env and pr_env.isdigit():
45
+ resolved_pr = int(pr_env)
46
+ elif "GITHUB_REF" in os.environ:
47
+ resolved_pr = parse_pr_number_from_ref(os.environ["GITHUB_REF"])
48
+
49
+ if resolved_pr is None:
50
+ raise GitHubApiError(
51
+ "Pull request number not specified. Pass '--pr <number>' or set PR_NUMBER / GITHUB_REF in CI."
52
+ )
53
+
54
+ resolved_token = token or os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
55
+ if not resolved_token:
56
+ raise GitHubApiError("GitHub token not found. Pass '--token <token>' or set GITHUB_TOKEN.")
57
+
58
+ return resolved_repo, resolved_pr, resolved_token
59
+
60
+
61
+ def _github_request(
62
+ url: str,
63
+ token: str,
64
+ method: str = "GET",
65
+ payload: dict[str, Any] | None = None,
66
+ ) -> Any:
67
+ """Execute an authenticated HTTP request against GitHub's REST API."""
68
+ headers = {
69
+ "Authorization": f"Bearer {token}",
70
+ "Accept": "application/vnd.github+json",
71
+ "User-Agent": f"Preflight-Engine/{__version__}",
72
+ "X-GitHub-Api-Version": "2022-11-28",
73
+ }
74
+ data = json.dumps(payload).encode("utf-8") if payload is not None else None
75
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
76
+
77
+ try:
78
+ with urllib.request.urlopen(req) as resp:
79
+ content = resp.read().decode("utf-8")
80
+ return json.loads(content) if content else {}
81
+ except urllib.error.HTTPError as exc:
82
+ err_msg = exc.read().decode("utf-8", errors="replace")
83
+ raise GitHubApiError(f"GitHub API error ({exc.code} {exc.reason}): {err_msg}") from exc
84
+ except urllib.error.URLError as exc:
85
+ raise GitHubApiError(f"Network error connecting to GitHub API: {exc.reason}") from exc
86
+
87
+
88
+ def post_or_update_pr_comment(
89
+ repo: str,
90
+ pr_number: int,
91
+ token: str,
92
+ comment_body: str,
93
+ marker: str = PR_COMMENT_MARKER,
94
+ ) -> dict[str, Any]:
95
+ """Publish a sticky PR comment, updating an existing comment if a marker is present."""
96
+ comments_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
97
+
98
+ # 1. Search for existing sticky comment with our marker
99
+ existing_comments = _github_request(f"{comments_url}?per_page=100", token, method="GET")
100
+ existing_comment_id: int | None = None
101
+
102
+ if isinstance(existing_comments, list):
103
+ for comment in existing_comments:
104
+ body = comment.get("body", "")
105
+ if marker in body:
106
+ existing_comment_id = int(comment["id"])
107
+ break
108
+
109
+ # 2. Update existing comment or create a new one
110
+ if existing_comment_id is not None:
111
+ patch_url = f"https://api.github.com/repos/{repo}/issues/comments/{existing_comment_id}"
112
+ resp = _github_request(patch_url, token, method="PATCH", payload={"body": comment_body})
113
+ return {
114
+ "action": "updated",
115
+ "comment_id": existing_comment_id,
116
+ "url": resp.get("html_url", ""),
117
+ }
118
+ else:
119
+ resp = _github_request(comments_url, token, method="POST", payload={"body": comment_body})
120
+ return {
121
+ "action": "created",
122
+ "comment_id": resp.get("id"),
123
+ "url": resp.get("html_url", ""),
124
+ }
preflight/models.py ADDED
@@ -0,0 +1,274 @@
1
+ """Domain models for Preflight infrastructure change risk analysis."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import StrEnum
5
+ from typing import Any
6
+
7
+
8
+ class Environment(StrEnum):
9
+ DEVELOPMENT = "development"
10
+ STAGING = "staging"
11
+ PRODUCTION = "production"
12
+
13
+ @classmethod
14
+ def from_str(cls, value: str) -> "Environment":
15
+ normalized = value.strip().lower()
16
+ if normalized in ("dev", "development"):
17
+ return cls.DEVELOPMENT
18
+ if normalized in ("stage", "staging"):
19
+ return cls.STAGING
20
+ if normalized in ("prod", "production"):
21
+ return cls.PRODUCTION
22
+ raise ValueError(f"Unknown environment '{value}'. Valid: development, staging, production.")
23
+
24
+
25
+ class ActionType(StrEnum):
26
+ CREATE = "create"
27
+ UPDATE = "update"
28
+ DELETE = "delete"
29
+ REPLACE = "replace"
30
+ READ = "read"
31
+ NOOP = "no-op"
32
+ UNKNOWN = "unknown"
33
+
34
+ @classmethod
35
+ def from_terraform_actions(cls, actions: list[str]) -> "ActionType":
36
+ if not actions:
37
+ return cls.NOOP
38
+ if actions == ["create"]:
39
+ return cls.CREATE
40
+ if actions == ["update"]:
41
+ return cls.UPDATE
42
+ if actions == ["delete"]:
43
+ return cls.DELETE
44
+ if actions == ["read"]:
45
+ return cls.READ
46
+ if actions == ["no-op"]:
47
+ return cls.NOOP
48
+ if sorted(actions) == ["create", "delete"]:
49
+ return cls.REPLACE
50
+ return cls.UNKNOWN
51
+
52
+
53
+ class Severity(StrEnum):
54
+ LOW = "low"
55
+ MODERATE = "moderate"
56
+ HIGH = "high"
57
+ CRITICAL = "critical"
58
+
59
+ @property
60
+ def level(self) -> int:
61
+ levels = {
62
+ Severity.LOW: 1,
63
+ Severity.MODERATE: 2,
64
+ Severity.HIGH: 3,
65
+ Severity.CRITICAL: 4,
66
+ }
67
+ return levels[self]
68
+
69
+ def __ge__(self, other: object) -> bool:
70
+ if not isinstance(other, Severity):
71
+ return NotImplemented
72
+ return self.level >= other.level
73
+
74
+ def __gt__(self, other: object) -> bool:
75
+ if not isinstance(other, Severity):
76
+ return NotImplemented
77
+ return self.level > other.level
78
+
79
+ def __le__(self, other: object) -> bool:
80
+ if not isinstance(other, Severity):
81
+ return NotImplemented
82
+ return self.level <= other.level
83
+
84
+ def __lt__(self, other: object) -> bool:
85
+ if not isinstance(other, Severity):
86
+ return NotImplemented
87
+ return self.level < other.level
88
+
89
+ @classmethod
90
+ def from_str(cls, value: str) -> "Severity":
91
+ normalized = value.strip().lower()
92
+ for member in cls:
93
+ if member.value == normalized:
94
+ return member
95
+ raise ValueError(f"Unknown severity '{value}'. Valid: low, moderate, high, critical.")
96
+
97
+
98
+ class RiskCategory(StrEnum):
99
+ LOW = "low"
100
+ MODERATE = "moderate"
101
+ HIGH = "high"
102
+ CRITICAL = "critical"
103
+
104
+ @classmethod
105
+ def from_score(cls, score: float) -> "RiskCategory":
106
+ if score < 25.0:
107
+ return cls.LOW
108
+ if score < 50.0:
109
+ return cls.MODERATE
110
+ if score < 75.0:
111
+ return cls.HIGH
112
+ return cls.CRITICAL
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class RiskFactors:
117
+ change_impact: float = 0.0
118
+ security_exposure: float = 0.0
119
+ reversibility: float = 0.0
120
+ environment_criticality: float = 0.0
121
+ uncertainty: float = 0.0
122
+
123
+ def to_dict(self) -> dict[str, float]:
124
+ return {
125
+ "change_impact": round(self.change_impact, 2),
126
+ "security_exposure": round(self.security_exposure, 2),
127
+ "reversibility": round(self.reversibility, 2),
128
+ "environment_criticality": round(self.environment_criticality, 2),
129
+ "uncertainty": round(self.uncertainty, 2),
130
+ }
131
+
132
+
133
+ @dataclass
134
+ class Finding:
135
+ rule_id: str
136
+ rule_name: str
137
+ severity: Severity
138
+ resource_address: str
139
+ resource_type: str
140
+ action: ActionType
141
+ what_changed: str
142
+ why_it_matters: str
143
+ remediation: str
144
+ risk_factors: RiskFactors
145
+ calculated_score: float
146
+ details: dict[str, Any] = field(default_factory=dict)
147
+
148
+ def to_dict(self) -> dict[str, Any]:
149
+ return {
150
+ "rule_id": self.rule_id,
151
+ "rule_name": self.rule_name,
152
+ "severity": self.severity.value,
153
+ "resource_address": self.resource_address,
154
+ "resource_type": self.resource_type,
155
+ "action": self.action.value,
156
+ "what_changed": self.what_changed,
157
+ "why_it_matters": self.why_it_matters,
158
+ "remediation": self.remediation,
159
+ "risk_factors": self.risk_factors.to_dict(),
160
+ "calculated_score": round(self.calculated_score, 1),
161
+ "details": self.details,
162
+ }
163
+
164
+
165
+ @dataclass
166
+ class ResourceChange:
167
+ address: str
168
+ mode: str
169
+ type: str
170
+ name: str
171
+ provider_name: str
172
+ raw_actions: list[str]
173
+ action: ActionType
174
+ module_address: str | None = None
175
+ before: dict[str, Any] | None = None
176
+ after: dict[str, Any] | None = None
177
+ after_unknown: dict[str, Any] | None = None
178
+ replace_paths: list[Any] | None = None
179
+ before_sensitive: Any = None
180
+ after_sensitive: Any = None
181
+
182
+
183
+ @dataclass
184
+ class ResourceRisk:
185
+ resource_address: str
186
+ resource_type: str
187
+ action: ActionType
188
+ risk_score: float
189
+ risk_category: RiskCategory
190
+ risk_factors: RiskFactors
191
+ findings: list[Finding] = field(default_factory=list)
192
+
193
+ def to_dict(self) -> dict[str, Any]:
194
+ return {
195
+ "resource_address": self.resource_address,
196
+ "resource_type": self.resource_type,
197
+ "action": self.action.value,
198
+ "risk_score": round(self.risk_score, 1),
199
+ "risk_category": self.risk_category.value,
200
+ "risk_factors": self.risk_factors.to_dict(),
201
+ "findings_count": len(self.findings),
202
+ "findings": [f.to_dict() for f in self.findings],
203
+ }
204
+
205
+
206
+ @dataclass
207
+ class PlanSummary:
208
+ total_resources: int = 0
209
+ to_create: int = 0
210
+ to_update: int = 0
211
+ to_delete: int = 0
212
+ to_replace: int = 0
213
+ to_read: int = 0
214
+ to_noop: int = 0
215
+ unknown_actions: int = 0
216
+ has_unknown_values: bool = False
217
+
218
+ def to_dict(self) -> dict[str, Any]:
219
+ return {
220
+ "total_resources": self.total_resources,
221
+ "to_create": self.to_create,
222
+ "to_update": self.to_update,
223
+ "to_delete": self.to_delete,
224
+ "to_replace": self.to_replace,
225
+ "to_read": self.to_read,
226
+ "to_noop": self.to_noop,
227
+ "unknown_actions": self.unknown_actions,
228
+ "has_unknown_values": self.has_unknown_values,
229
+ }
230
+
231
+
232
+ @dataclass
233
+ class PolicyEvaluation:
234
+ blocked: bool
235
+ blocking_reasons: list[str] = field(default_factory=list)
236
+ fail_on_severity: Severity | None = None
237
+ blocked_rule_ids: list[str] = field(default_factory=list)
238
+
239
+ def to_dict(self) -> dict[str, Any]:
240
+ return {
241
+ "blocked": self.blocked,
242
+ "blocking_reasons": self.blocking_reasons,
243
+ "fail_on_severity": self.fail_on_severity.value if self.fail_on_severity else None,
244
+ "blocked_rule_ids": self.blocked_rule_ids,
245
+ }
246
+
247
+
248
+ @dataclass
249
+ class AnalysisResult:
250
+ plan_path: str
251
+ environment: Environment
252
+ summary: PlanSummary
253
+ resources: list[ResourceRisk]
254
+ findings: list[Finding]
255
+ overall_risk_score: float
256
+ overall_risk_category: RiskCategory
257
+ highest_contributing_resource: str | None
258
+ policy_evaluation: PolicyEvaluation
259
+ timestamp: str
260
+
261
+ def to_dict(self) -> dict[str, Any]:
262
+ return {
263
+ "plan_path": self.plan_path,
264
+ "timestamp": self.timestamp,
265
+ "environment": self.environment.value,
266
+ "overall_risk_score": round(self.overall_risk_score, 1),
267
+ "overall_risk_category": self.overall_risk_category.value,
268
+ "highest_contributing_resource": self.highest_contributing_resource,
269
+ "summary": self.summary.to_dict(),
270
+ "policy_evaluation": self.policy_evaluation.to_dict(),
271
+ "findings_count": len(self.findings),
272
+ "findings": [f.to_dict() for f in self.findings],
273
+ "resources": [r.to_dict() for r in self.resources],
274
+ }
preflight/plan.py ADDED
@@ -0,0 +1,165 @@
1
+ """Input layer: Read, validate, and normalize Terraform plan JSON."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from preflight.models import ActionType, PlanSummary, ResourceChange
8
+
9
+
10
+ class PlanParseError(Exception):
11
+ """Raised when a Terraform plan file cannot be read or parsed."""
12
+
13
+
14
+ def parse_plan_file(plan_path: str | Path) -> tuple[list[ResourceChange], PlanSummary]:
15
+ """Read and validate a Terraform plan JSON file or stdin stream."""
16
+ path_str = str(plan_path)
17
+ if path_str == "-":
18
+ if hasattr(sys.stdin, "isatty") and sys.stdin.isatty():
19
+ raise PlanParseError(
20
+ "Standard input is an interactive terminal. Pipe data in via '|' "
21
+ "(e.g., terraform show -json tfplan | preflight analyze -) or specify a plan file path."
22
+ )
23
+ try:
24
+ content = sys.stdin.read()
25
+ except OSError as exc:
26
+ raise PlanParseError(f"Failed to read from standard input: {exc}") from exc
27
+ if not content.strip():
28
+ raise PlanParseError("Standard input is empty.")
29
+ return parse_plan_json(content)
30
+
31
+ path = Path(plan_path)
32
+ if not path.exists():
33
+ raise PlanParseError(f"Plan file not found: '{path}'. Ensure the path is correct.")
34
+ if path.is_dir():
35
+ raise PlanParseError(f"Plan path '{path}' is a directory, not a file.")
36
+
37
+ try:
38
+ content = path.read_text(encoding="utf-8")
39
+ except UnicodeDecodeError as exc:
40
+ raise PlanParseError(f"File '{path}' is not valid UTF-8 text: {exc}") from exc
41
+ except OSError as exc:
42
+ raise PlanParseError(f"Failed to read file '{path}': {exc}") from exc
43
+
44
+ return parse_plan_json(content)
45
+
46
+
47
+ def parse_plan_json(json_content: str) -> tuple[list[ResourceChange], PlanSummary]:
48
+ """Parse raw JSON string representation of a Terraform plan."""
49
+ if not json_content.strip():
50
+ raise PlanParseError("Plan file is empty.")
51
+
52
+ try:
53
+ data = json.loads(json_content)
54
+ except json.JSONDecodeError as exc:
55
+ raise PlanParseError(
56
+ f"Invalid JSON syntax at line {exc.lineno}, column {exc.colno}: {exc.msg}"
57
+ ) from exc
58
+
59
+ if not isinstance(data, dict):
60
+ raise PlanParseError(
61
+ f"Unsupported plan format: expected JSON object at root, got {type(data).__name__}."
62
+ )
63
+
64
+ # Validate Terraform plan signature (must have format_version or resource_changes or planned_values)
65
+ is_tf_plan = any(
66
+ k in data
67
+ for k in ("format_version", "terraform_version", "resource_changes", "planned_values")
68
+ )
69
+ if not is_tf_plan:
70
+ raise PlanParseError(
71
+ "JSON structure does not appear to be a Terraform plan. "
72
+ "Ensure you generated it via 'terraform show -json <tfplan>'."
73
+ )
74
+
75
+ raw_changes = data.get("resource_changes", [])
76
+ if not isinstance(raw_changes, list):
77
+ raise PlanParseError(
78
+ f"'resource_changes' must be an array, got {type(raw_changes).__name__}."
79
+ )
80
+
81
+ resource_changes: list[ResourceChange] = []
82
+ summary = PlanSummary(total_resources=len(raw_changes))
83
+
84
+ for idx, item in enumerate(raw_changes):
85
+ if not isinstance(item, dict):
86
+ raise PlanParseError(f"resource_changes[{idx}] is not an object.")
87
+
88
+ address = str(item.get("address", f"resource_{idx}"))
89
+ mode = str(item.get("mode", "managed"))
90
+ res_type = str(item.get("type", "unknown"))
91
+ name = str(item.get("name", "unknown"))
92
+ provider_name = str(item.get("provider_name", "unknown"))
93
+ module_address = item.get("module_address")
94
+
95
+ change = item.get("change")
96
+ if not isinstance(change, dict):
97
+ actions = ["unknown"]
98
+ before = None
99
+ after = None
100
+ after_unknown = None
101
+ replace_paths = None
102
+ before_sensitive = None
103
+ after_sensitive = None
104
+ else:
105
+ raw_actions = change.get("actions", [])
106
+ actions = (
107
+ [str(a) for a in raw_actions] if isinstance(raw_actions, list) else ["unknown"]
108
+ )
109
+ before = change.get("before") if isinstance(change.get("before"), dict) else None
110
+ after = change.get("after") if isinstance(change.get("after"), dict) else None
111
+ after_unknown = (
112
+ change.get("after_unknown")
113
+ if isinstance(change.get("after_unknown"), dict)
114
+ else None
115
+ )
116
+ replace_paths = (
117
+ change.get("replace_paths")
118
+ if isinstance(change.get("replace_paths"), list)
119
+ else None
120
+ )
121
+ before_sensitive = change.get("before_sensitive")
122
+ after_sensitive = change.get("after_sensitive")
123
+
124
+ action_type = ActionType.from_terraform_actions(actions)
125
+
126
+ # Update summary counts
127
+ match action_type:
128
+ case ActionType.CREATE:
129
+ summary.to_create += 1
130
+ case ActionType.UPDATE:
131
+ summary.to_update += 1
132
+ case ActionType.DELETE:
133
+ summary.to_delete += 1
134
+ case ActionType.REPLACE:
135
+ summary.to_replace += 1
136
+ case ActionType.READ:
137
+ summary.to_read += 1
138
+ case ActionType.NOOP:
139
+ summary.to_noop += 1
140
+ case ActionType.UNKNOWN:
141
+ summary.unknown_actions += 1
142
+
143
+ if after_unknown and any(v for v in after_unknown.values()):
144
+ summary.has_unknown_values = True
145
+
146
+ resource_changes.append(
147
+ ResourceChange(
148
+ address=address,
149
+ module_address=str(module_address) if module_address else None,
150
+ mode=mode,
151
+ type=res_type,
152
+ name=name,
153
+ provider_name=provider_name,
154
+ raw_actions=actions,
155
+ action=action_type,
156
+ before=before,
157
+ after=after,
158
+ after_unknown=after_unknown,
159
+ replace_paths=replace_paths,
160
+ before_sensitive=before_sensitive,
161
+ after_sensitive=after_sensitive,
162
+ )
163
+ )
164
+
165
+ return resource_changes, summary