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
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
|