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/policy.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Policy evaluation engine: Enforces configurable deployment gates."""
|
|
2
|
+
|
|
3
|
+
from preflight.models import Finding, PolicyEvaluation, Severity
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class PolicyEvaluator:
|
|
7
|
+
"""Evaluates findings and risk scores against configured policy rules."""
|
|
8
|
+
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
fail_on_severity: Severity | None = None,
|
|
12
|
+
blocked_rules: list[str] | None = None,
|
|
13
|
+
allowed_rules: list[str] | None = None,
|
|
14
|
+
max_allowed_score: float | None = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
self.fail_on_severity = fail_on_severity
|
|
17
|
+
self.blocked_rules = set(blocked_rules or [])
|
|
18
|
+
self.allowed_rules = set(allowed_rules or [])
|
|
19
|
+
self.max_allowed_score = max_allowed_score
|
|
20
|
+
|
|
21
|
+
def evaluate(
|
|
22
|
+
self,
|
|
23
|
+
findings: list[Finding],
|
|
24
|
+
overall_risk_score: float,
|
|
25
|
+
) -> PolicyEvaluation:
|
|
26
|
+
"""Evaluate policy gates against discovered findings and overall score."""
|
|
27
|
+
blocking_reasons: list[str] = []
|
|
28
|
+
triggered_rule_ids: set[str] = set()
|
|
29
|
+
|
|
30
|
+
# Filter out findings explicitly suppressed by allowed_rules
|
|
31
|
+
active_findings = [f for f in findings if f.rule_id not in self.allowed_rules]
|
|
32
|
+
|
|
33
|
+
# 1. Check severity threshold
|
|
34
|
+
if self.fail_on_severity is not None:
|
|
35
|
+
violating_findings = [f for f in active_findings if f.severity >= self.fail_on_severity]
|
|
36
|
+
for f in violating_findings:
|
|
37
|
+
triggered_rule_ids.add(f.rule_id)
|
|
38
|
+
blocking_reasons.append(
|
|
39
|
+
f"Finding '{f.rule_id}' on '{f.resource_address}' has severity '{f.severity.value}' "
|
|
40
|
+
f"which meets or exceeds policy threshold '{self.fail_on_severity.value}'."
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
# 2. Check explicitly blocked rule IDs
|
|
44
|
+
if self.blocked_rules:
|
|
45
|
+
for f in active_findings:
|
|
46
|
+
if f.rule_id in self.blocked_rules:
|
|
47
|
+
triggered_rule_ids.add(f.rule_id)
|
|
48
|
+
reason = f"Rule '{f.rule_id}' on '{f.resource_address}' is explicitly blocked by policy."
|
|
49
|
+
if reason not in blocking_reasons:
|
|
50
|
+
blocking_reasons.append(reason)
|
|
51
|
+
|
|
52
|
+
# 3. Check maximum allowed score threshold
|
|
53
|
+
if self.max_allowed_score is not None:
|
|
54
|
+
if overall_risk_score > self.max_allowed_score:
|
|
55
|
+
blocking_reasons.append(
|
|
56
|
+
f"Overall plan risk score ({overall_risk_score:.1f}) exceeds configured "
|
|
57
|
+
f"maximum allowed score ({self.max_allowed_score:.1f})."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
blocked = len(blocking_reasons) > 0
|
|
61
|
+
|
|
62
|
+
return PolicyEvaluation(
|
|
63
|
+
blocked=blocked,
|
|
64
|
+
blocking_reasons=blocking_reasons,
|
|
65
|
+
fail_on_severity=self.fail_on_severity,
|
|
66
|
+
blocked_rule_ids=sorted(triggered_rule_ids),
|
|
67
|
+
)
|
preflight/redaction.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Defensive redaction of sensitive values from Terraform plans and findings."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# Attribute name patterns that indicate sensitive or secret data
|
|
7
|
+
SENSITIVE_KEY_PATTERNS = re.compile(
|
|
8
|
+
r"(password|secret|token|api_key|access_key|private_key|credential|auth|"
|
|
9
|
+
r"cert|bearer|master_key|client_secret|ssh_key|encryption_key)",
|
|
10
|
+
re.IGNORECASE,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
REDACTED_PLACEHOLDER = "[REDACTED]"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_sensitive_key(key: str) -> bool:
|
|
17
|
+
"""Check if an attribute key matches sensitive naming patterns."""
|
|
18
|
+
return bool(SENSITIVE_KEY_PATTERNS.search(key))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def redact_sensitive_data(
|
|
22
|
+
data: Any,
|
|
23
|
+
sensitive_marks: Any = None,
|
|
24
|
+
) -> Any:
|
|
25
|
+
"""Recursively redact sensitive values from a dictionary, list, or primitive.
|
|
26
|
+
|
|
27
|
+
Respects both known sensitive key names and Terraform's sensitive_values metadata.
|
|
28
|
+
"""
|
|
29
|
+
if sensitive_marks is True:
|
|
30
|
+
return REDACTED_PLACEHOLDER
|
|
31
|
+
|
|
32
|
+
if isinstance(data, dict):
|
|
33
|
+
redacted_dict: dict[str, Any] = {}
|
|
34
|
+
for key, value in data.items():
|
|
35
|
+
child_marks = sensitive_marks.get(key) if isinstance(sensitive_marks, dict) else None
|
|
36
|
+
|
|
37
|
+
if child_marks is True or is_sensitive_key(str(key)):
|
|
38
|
+
redacted_dict[key] = REDACTED_PLACEHOLDER
|
|
39
|
+
else:
|
|
40
|
+
redacted_dict[key] = redact_sensitive_data(value, child_marks)
|
|
41
|
+
return redacted_dict
|
|
42
|
+
|
|
43
|
+
if isinstance(data, list):
|
|
44
|
+
redacted_list: list[Any] = []
|
|
45
|
+
for index, item in enumerate(data):
|
|
46
|
+
child_marks = (
|
|
47
|
+
sensitive_marks[index]
|
|
48
|
+
if isinstance(sensitive_marks, list) and index < len(sensitive_marks)
|
|
49
|
+
else None
|
|
50
|
+
)
|
|
51
|
+
redacted_list.append(redact_sensitive_data(item, child_marks))
|
|
52
|
+
return redacted_list
|
|
53
|
+
|
|
54
|
+
return data
|
preflight/reporting.py
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
"""Reporting layer: Renders terminal, JSON, Markdown, SARIF, and PR comment reports."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from io import StringIO
|
|
5
|
+
from typing import Any, TextIO
|
|
6
|
+
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.panel import Panel
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from preflight import __version__
|
|
13
|
+
from preflight.models import (
|
|
14
|
+
AnalysisResult,
|
|
15
|
+
RiskCategory,
|
|
16
|
+
Severity,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
PR_COMMENT_MARKER = "<!-- preflight-comment-marker -->"
|
|
20
|
+
|
|
21
|
+
# Color palettes for severities and risk bands
|
|
22
|
+
SEVERITY_STYLES = {
|
|
23
|
+
Severity.CRITICAL: "bold white on red",
|
|
24
|
+
Severity.HIGH: "bold red",
|
|
25
|
+
Severity.MODERATE: "bold yellow",
|
|
26
|
+
Severity.LOW: "bold cyan",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
RISK_CATEGORY_STYLES = {
|
|
30
|
+
RiskCategory.CRITICAL: "bold red",
|
|
31
|
+
RiskCategory.HIGH: "bold orange3",
|
|
32
|
+
RiskCategory.MODERATE: "bold yellow",
|
|
33
|
+
RiskCategory.LOW: "bold green",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render_terminal_report(result: AnalysisResult, file: TextIO | None = None) -> None:
|
|
38
|
+
"""Render a clean, human-readable terminal report with Rich."""
|
|
39
|
+
console = Console(file=file, highlight=False, force_terminal=False if file else None)
|
|
40
|
+
|
|
41
|
+
# 1. Header Banner
|
|
42
|
+
cat_style = RISK_CATEGORY_STYLES.get(result.overall_risk_category, "bold white")
|
|
43
|
+
score_text = (
|
|
44
|
+
f"{result.overall_risk_score:.1f} / 100 ({result.overall_risk_category.value.upper()})"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
header_text = Text()
|
|
48
|
+
header_text.append("PREFLIGHT: Infrastructure Change Risk Analysis\n", style="bold cyan")
|
|
49
|
+
header_text.append(f"Plan: {result.plan_path}\n", style="dim")
|
|
50
|
+
header_text.append(f"Environment: {result.environment.value.upper()}\n", style="dim")
|
|
51
|
+
header_text.append("Risk Score: ")
|
|
52
|
+
header_text.append(score_text, style=cat_style)
|
|
53
|
+
|
|
54
|
+
console.print(Panel(header_text, border_style="dim", expand=False))
|
|
55
|
+
|
|
56
|
+
# 2. Plan Actions Summary Table
|
|
57
|
+
summary = result.summary
|
|
58
|
+
summary_table = Table(title="Planned Resource Operations", border_style="dim", show_header=True)
|
|
59
|
+
summary_table.add_column("Create", style="green", justify="center")
|
|
60
|
+
summary_table.add_column("Update", style="yellow", justify="center")
|
|
61
|
+
summary_table.add_column("Delete", style="red", justify="center")
|
|
62
|
+
summary_table.add_column("Replace", style="bold red", justify="center")
|
|
63
|
+
summary_table.add_column("Read / No-op", style="dim", justify="center")
|
|
64
|
+
summary_table.add_column("Unknown Actions", style="magenta", justify="center")
|
|
65
|
+
|
|
66
|
+
summary_table.add_row(
|
|
67
|
+
str(summary.to_create),
|
|
68
|
+
str(summary.to_update),
|
|
69
|
+
str(summary.to_delete),
|
|
70
|
+
str(summary.to_replace),
|
|
71
|
+
f"{summary.to_read} / {summary.to_noop}",
|
|
72
|
+
str(summary.unknown_actions),
|
|
73
|
+
)
|
|
74
|
+
console.print(summary_table)
|
|
75
|
+
|
|
76
|
+
# 3. Findings Section
|
|
77
|
+
if not result.findings:
|
|
78
|
+
console.print(
|
|
79
|
+
"\n[bold green][PASS] No risk findings detected.[/bold green] Infrastructure changes appear safe.\n"
|
|
80
|
+
)
|
|
81
|
+
else:
|
|
82
|
+
console.print(f"\n[bold]Risk Findings ({len(result.findings)}):[/bold]\n")
|
|
83
|
+
|
|
84
|
+
for idx, finding in enumerate(result.findings, start=1):
|
|
85
|
+
sev_style = SEVERITY_STYLES.get(finding.severity, "bold white")
|
|
86
|
+
f_title = Text()
|
|
87
|
+
f_title.append(f"#{idx} [{finding.rule_id}] ", style="bold")
|
|
88
|
+
f_title.append(f" {finding.severity.value.upper()} ", style=sev_style)
|
|
89
|
+
f_title.append(f" {finding.rule_name} (Score: {finding.calculated_score:.1f})")
|
|
90
|
+
|
|
91
|
+
body = Text()
|
|
92
|
+
body.append("Resource: ", style="bold")
|
|
93
|
+
body.append(f"{finding.resource_address} ({finding.resource_type})\n")
|
|
94
|
+
body.append("Action: ", style="bold")
|
|
95
|
+
body.append(f"{finding.action.value.upper()}\n\n")
|
|
96
|
+
|
|
97
|
+
body.append("What changed:\n", style="bold")
|
|
98
|
+
body.append(f" {finding.what_changed}\n\n")
|
|
99
|
+
|
|
100
|
+
body.append("Why it matters:\n", style="bold")
|
|
101
|
+
body.append(f" {finding.why_it_matters}\n\n")
|
|
102
|
+
|
|
103
|
+
# Risk factors breakdown
|
|
104
|
+
rf = finding.risk_factors
|
|
105
|
+
body.append("Risk Factor Breakdown:\n", style="bold")
|
|
106
|
+
body.append(
|
|
107
|
+
f" Change Impact: {rf.change_impact:.1f}/5 | "
|
|
108
|
+
f"Security Exposure: {rf.security_exposure:.1f}/5 | "
|
|
109
|
+
f"Reversibility: {rf.reversibility:.1f}/5\n"
|
|
110
|
+
f" Env Criticality: {rf.environment_criticality:.1f}/5 | "
|
|
111
|
+
f"Uncertainty: {rf.uncertainty:.1f}/5\n\n",
|
|
112
|
+
style="cyan",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
body.append("Remediation / Next Steps:\n", style="bold")
|
|
116
|
+
body.append(f" {finding.remediation}\n", style="green")
|
|
117
|
+
|
|
118
|
+
console.print(Panel(body, title=f_title, border_style="dim", expand=False))
|
|
119
|
+
|
|
120
|
+
# 4. Policy Gate Status
|
|
121
|
+
policy = result.policy_evaluation
|
|
122
|
+
if policy.blocked:
|
|
123
|
+
gate_text = Text()
|
|
124
|
+
gate_text.append("DEPLOYMENT BLOCKED BY POLICY\n", style="bold red")
|
|
125
|
+
for reason in policy.blocking_reasons:
|
|
126
|
+
gate_text.append(f"- {reason}\n", style="red")
|
|
127
|
+
console.print(Panel(gate_text, border_style="red", expand=False))
|
|
128
|
+
else:
|
|
129
|
+
status_msg = "PASSED: No configured policy conditions were violated."
|
|
130
|
+
console.print(
|
|
131
|
+
Panel(Text(status_msg, style="bold green"), border_style="green", expand=False)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def render_json_report(result: AnalysisResult) -> str:
|
|
136
|
+
"""Render a deterministic, redacted JSON report."""
|
|
137
|
+
return json.dumps(result.to_dict(), indent=2, sort_keys=False)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render_markdown_report(result: AnalysisResult) -> str:
|
|
141
|
+
"""Render a sanitized Markdown report suitable for GitHub PR comments and CI summaries."""
|
|
142
|
+
lines: list[str] = []
|
|
143
|
+
|
|
144
|
+
# Title & Badge
|
|
145
|
+
lines.append("# Preflight Infrastructure Risk Report\n")
|
|
146
|
+
|
|
147
|
+
policy = result.policy_evaluation
|
|
148
|
+
if policy.blocked:
|
|
149
|
+
lines.append("> **Deployment Blocked by Preflight Policy**\n")
|
|
150
|
+
for reason in policy.blocking_reasons:
|
|
151
|
+
lines.append(f"> - {reason}")
|
|
152
|
+
lines.append("")
|
|
153
|
+
else:
|
|
154
|
+
lines.append("> **Preflight Passed** — No configured blocking policies were triggered.\n")
|
|
155
|
+
|
|
156
|
+
# Overview table
|
|
157
|
+
lines.append("## Overview\n")
|
|
158
|
+
lines.append("| Metric | Value |")
|
|
159
|
+
lines.append("|---|---|")
|
|
160
|
+
lines.append(f"| **Plan Path** | `{result.plan_path}` |")
|
|
161
|
+
lines.append(f"| **Environment** | `{result.environment.value.upper()}` |")
|
|
162
|
+
lines.append(f"| **Overall Risk Score** | **{result.overall_risk_score:.1f} / 100** |")
|
|
163
|
+
lines.append(f"| **Risk Category** | `{result.overall_risk_category.value.upper()}` |")
|
|
164
|
+
if result.highest_contributing_resource:
|
|
165
|
+
lines.append(f"| **Highest Risk Resource** | `{result.highest_contributing_resource}` |")
|
|
166
|
+
lines.append(f"| **Total Findings** | {len(result.findings)} |")
|
|
167
|
+
lines.append("")
|
|
168
|
+
|
|
169
|
+
# Operations breakdown
|
|
170
|
+
summary = result.summary
|
|
171
|
+
lines.append("### Planned Operations\n")
|
|
172
|
+
lines.append("| Create | Update | Delete | Replace | Read / No-op | Unknown Actions |")
|
|
173
|
+
lines.append("|:---:|:---:|:---:|:---:|:---:|:---:|")
|
|
174
|
+
lines.append(
|
|
175
|
+
f"| {summary.to_create} | {summary.to_update} | {summary.to_delete} | "
|
|
176
|
+
f"{summary.to_replace} | {summary.to_read} / {summary.to_noop} | {summary.unknown_actions} |"
|
|
177
|
+
)
|
|
178
|
+
lines.append("")
|
|
179
|
+
|
|
180
|
+
# Findings
|
|
181
|
+
if not result.findings:
|
|
182
|
+
lines.append("## Findings\n")
|
|
183
|
+
lines.append("No risk findings detected for this Terraform plan.\n")
|
|
184
|
+
else:
|
|
185
|
+
lines.append(f"## Risk Findings ({len(result.findings)})\n")
|
|
186
|
+
for idx, finding in enumerate(result.findings, start=1):
|
|
187
|
+
sev_badge = f"**{finding.severity.value.upper()}**"
|
|
188
|
+
lines.append(f"### {idx}. [{finding.rule_id}] {finding.rule_name} — {sev_badge}\n")
|
|
189
|
+
lines.append(
|
|
190
|
+
f"- **Resource:** `{finding.resource_address}` (`{finding.resource_type}`)"
|
|
191
|
+
)
|
|
192
|
+
lines.append(f"- **Action:** `{finding.action.value.upper()}`")
|
|
193
|
+
lines.append(f"- **Risk Score:** `{finding.calculated_score:.1f}`")
|
|
194
|
+
lines.append(f"- **What Changed:** {finding.what_changed}")
|
|
195
|
+
lines.append(f"- **Why It Matters:** {finding.why_it_matters}")
|
|
196
|
+
|
|
197
|
+
rf = finding.risk_factors
|
|
198
|
+
lines.append(
|
|
199
|
+
f"- **Risk Factors:** Impact: `{rf.change_impact:.1f}/5` | "
|
|
200
|
+
f"Security: `{rf.security_exposure:.1f}/5` | "
|
|
201
|
+
f"Reversibility: `{rf.reversibility:.1f}/5` | "
|
|
202
|
+
f"Env: `{rf.environment_criticality:.1f}/5` | "
|
|
203
|
+
f"Uncertainty: `{rf.uncertainty:.1f}/5`"
|
|
204
|
+
)
|
|
205
|
+
lines.append(f"- **Remediation:** {finding.remediation}\n")
|
|
206
|
+
|
|
207
|
+
lines.append("---")
|
|
208
|
+
lines.append(
|
|
209
|
+
"*Generated by [Preflight](https://github.com/preflight-engine/preflight) — Infrastructure Change Risk Analysis Engine.*"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
return "\n".join(lines)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def get_terminal_report_str(result: AnalysisResult) -> str:
|
|
216
|
+
"""Helper to get the terminal report as a plain string (useful for testing or writing to file)."""
|
|
217
|
+
buf = StringIO()
|
|
218
|
+
render_terminal_report(result, file=buf)
|
|
219
|
+
return buf.getvalue()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _severity_to_sarif_level(severity: Severity) -> str:
|
|
223
|
+
if severity in (Severity.CRITICAL, Severity.HIGH):
|
|
224
|
+
return "error"
|
|
225
|
+
if severity == Severity.MODERATE:
|
|
226
|
+
return "warning"
|
|
227
|
+
return "note"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _severity_to_security_score(severity: Severity) -> str:
|
|
231
|
+
if severity == Severity.CRITICAL:
|
|
232
|
+
return "9.0"
|
|
233
|
+
if severity == Severity.HIGH:
|
|
234
|
+
return "7.5"
|
|
235
|
+
if severity == Severity.MODERATE:
|
|
236
|
+
return "5.0"
|
|
237
|
+
return "2.5"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def render_sarif_report(result: AnalysisResult, indent: int = 2) -> str:
|
|
241
|
+
"""Render analysis findings as an OASIS SARIF v2.1.0 document."""
|
|
242
|
+
rules_dict: dict[str, dict[str, Any]] = {}
|
|
243
|
+
sarif_results: list[dict[str, Any]] = []
|
|
244
|
+
|
|
245
|
+
artifact_uri = "plan.json" if result.plan_path in ("-", "<stdin>") else result.plan_path
|
|
246
|
+
|
|
247
|
+
for finding in result.findings:
|
|
248
|
+
if finding.rule_id not in rules_dict:
|
|
249
|
+
rules_dict[finding.rule_id] = {
|
|
250
|
+
"id": finding.rule_id,
|
|
251
|
+
"name": finding.rule_id.replace("-", ""),
|
|
252
|
+
"shortDescription": {"text": finding.rule_name},
|
|
253
|
+
"fullDescription": {"text": finding.why_it_matters},
|
|
254
|
+
"help": {
|
|
255
|
+
"text": finding.remediation,
|
|
256
|
+
"markdown": (
|
|
257
|
+
f"### Remediation\n\n{finding.remediation}\n\n"
|
|
258
|
+
f"### Why It Matters\n\n{finding.why_it_matters}"
|
|
259
|
+
),
|
|
260
|
+
},
|
|
261
|
+
"defaultConfiguration": {
|
|
262
|
+
"level": _severity_to_sarif_level(finding.severity),
|
|
263
|
+
},
|
|
264
|
+
"properties": {
|
|
265
|
+
"precision": "high",
|
|
266
|
+
"security-severity": _severity_to_security_score(finding.severity),
|
|
267
|
+
"tags": ["security", "infrastructure", "terraform"],
|
|
268
|
+
},
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
sorted_rule_ids = sorted(rules_dict.keys())
|
|
272
|
+
rules_list = [rules_dict[rid] for rid in sorted_rule_ids]
|
|
273
|
+
rule_index_map = {rid: idx for idx, rid in enumerate(sorted_rule_ids)}
|
|
274
|
+
|
|
275
|
+
for finding in result.findings:
|
|
276
|
+
sarif_results.append(
|
|
277
|
+
{
|
|
278
|
+
"ruleId": finding.rule_id,
|
|
279
|
+
"ruleIndex": rule_index_map[finding.rule_id],
|
|
280
|
+
"level": _severity_to_sarif_level(finding.severity),
|
|
281
|
+
"message": {
|
|
282
|
+
"text": (
|
|
283
|
+
f"{finding.what_changed}\n\n"
|
|
284
|
+
f"Why it matters: {finding.why_it_matters}\n"
|
|
285
|
+
f"Remediation: {finding.remediation}"
|
|
286
|
+
),
|
|
287
|
+
},
|
|
288
|
+
"locations": [
|
|
289
|
+
{
|
|
290
|
+
"physicalLocation": {
|
|
291
|
+
"artifactLocation": {
|
|
292
|
+
"uri": artifact_uri,
|
|
293
|
+
"uriBaseId": "%SRCROOT%",
|
|
294
|
+
},
|
|
295
|
+
"region": {
|
|
296
|
+
"startLine": 1,
|
|
297
|
+
"startColumn": 1,
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
"logicalLocations": [
|
|
301
|
+
{
|
|
302
|
+
"name": finding.resource_address,
|
|
303
|
+
"kind": "resource",
|
|
304
|
+
"fullyQualifiedName": f"{finding.resource_type}.{finding.resource_address}",
|
|
305
|
+
}
|
|
306
|
+
],
|
|
307
|
+
}
|
|
308
|
+
],
|
|
309
|
+
"properties": {
|
|
310
|
+
"resourceAddress": finding.resource_address,
|
|
311
|
+
"resourceType": finding.resource_type,
|
|
312
|
+
"action": finding.action.value,
|
|
313
|
+
"riskScore": finding.calculated_score,
|
|
314
|
+
"severity": finding.severity.value,
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
sarif_doc = {
|
|
320
|
+
"$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
|
|
321
|
+
"version": "2.1.0",
|
|
322
|
+
"runs": [
|
|
323
|
+
{
|
|
324
|
+
"tool": {
|
|
325
|
+
"driver": {
|
|
326
|
+
"name": "Preflight",
|
|
327
|
+
"semanticVersion": __version__,
|
|
328
|
+
"informationUri": "https://github.com/preflight-engine/preflight",
|
|
329
|
+
"rules": rules_list,
|
|
330
|
+
}
|
|
331
|
+
},
|
|
332
|
+
"results": sarif_results,
|
|
333
|
+
}
|
|
334
|
+
],
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return json.dumps(sarif_doc, indent=indent)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def render_pr_comment(result: AnalysisResult) -> str:
|
|
341
|
+
"""Render a collapsible, sticky markdown comment for GitHub pull requests."""
|
|
342
|
+
cat_badge = f"**{result.overall_risk_category.value.upper()}**"
|
|
343
|
+
policy_badge = "**BLOCKED**" if result.policy_evaluation.blocked else "**PASSED**"
|
|
344
|
+
|
|
345
|
+
lines: list[str] = [
|
|
346
|
+
PR_COMMENT_MARKER,
|
|
347
|
+
"## Preflight Infrastructure Risk Analysis\n",
|
|
348
|
+
"| Status | Risk Score | Environment | Policy Gate |",
|
|
349
|
+
"| :---: | :---: | :---: | :---: |",
|
|
350
|
+
f"| {cat_badge} | **{result.overall_risk_score:.1f} / 100** | `{result.environment.value.upper()}` | {policy_badge} |\n",
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
if result.policy_evaluation.blocked:
|
|
354
|
+
lines.append("> **Deployment Blocked by Policy:**")
|
|
355
|
+
for reason in result.policy_evaluation.blocking_reasons:
|
|
356
|
+
lines.append(f"> - {reason}")
|
|
357
|
+
lines.append("")
|
|
358
|
+
|
|
359
|
+
summary = result.summary
|
|
360
|
+
lines.append("### Planned Resource Operations\n")
|
|
361
|
+
lines.append("| Create | Update | Delete | Replace | Read / No-op | Unknown Actions |")
|
|
362
|
+
lines.append("| :---: | :---: | :---: | :---: | :---: | :---: |")
|
|
363
|
+
lines.append(
|
|
364
|
+
f"| {summary.to_create} | {summary.to_update} | {summary.to_delete} | "
|
|
365
|
+
f"{summary.to_replace} | {summary.to_read} / {summary.to_noop} | {summary.unknown_actions} |\n"
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
if not result.findings:
|
|
369
|
+
lines.append("### Findings\n")
|
|
370
|
+
lines.append(
|
|
371
|
+
"> **No risk findings detected.** All planned changes are within acceptable thresholds.\n"
|
|
372
|
+
)
|
|
373
|
+
else:
|
|
374
|
+
lines.append(f"### Risk Findings ({len(result.findings)})\n")
|
|
375
|
+
for idx, finding in enumerate(result.findings, start=1):
|
|
376
|
+
open_attr = " open" if idx <= 3 or finding.severity == Severity.CRITICAL else ""
|
|
377
|
+
lines.append(f"<details{open_attr}>")
|
|
378
|
+
lines.append(
|
|
379
|
+
f"<summary><b>[{finding.rule_id}] {finding.rule_name} — {finding.severity.value.upper()} (Score: {finding.calculated_score:.1f})</b></summary>\n"
|
|
380
|
+
)
|
|
381
|
+
lines.append(
|
|
382
|
+
f"- **Resource:** `{finding.resource_address}` (`{finding.resource_type}`)"
|
|
383
|
+
)
|
|
384
|
+
lines.append(f"- **Action:** `{finding.action.value.upper()}`")
|
|
385
|
+
lines.append(f"- **What Changed:** {finding.what_changed}")
|
|
386
|
+
lines.append(f"- **Why It Matters:** {finding.why_it_matters}")
|
|
387
|
+
|
|
388
|
+
rf = finding.risk_factors
|
|
389
|
+
lines.append(
|
|
390
|
+
f"- **Risk Factors:** Impact: `{rf.change_impact:.1f}/5` | "
|
|
391
|
+
f"Security: `{rf.security_exposure:.1f}/5` | "
|
|
392
|
+
f"Reversibility: `{rf.reversibility:.1f}/5` | "
|
|
393
|
+
f"Env: `{rf.environment_criticality:.1f}/5` | "
|
|
394
|
+
f"Uncertainty: `{rf.uncertainty:.1f}/5`"
|
|
395
|
+
)
|
|
396
|
+
lines.append(f"- **Remediation:** {finding.remediation}\n")
|
|
397
|
+
lines.append("</details>\n")
|
|
398
|
+
|
|
399
|
+
lines.append("---")
|
|
400
|
+
lines.append(
|
|
401
|
+
"*Updated automatically by [Preflight](https://github.com/preflight-engine/preflight) — Infrastructure Change Risk Analysis Engine.*"
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Rule packs and registry for Preflight."""
|
|
2
|
+
|
|
3
|
+
from preflight.rules.aws import (
|
|
4
|
+
AwsDatabaseDeletionReplacementRule,
|
|
5
|
+
AwsIamWildcardRule,
|
|
6
|
+
AwsPublicAdminIngressRule,
|
|
7
|
+
AwsS3PublicAccessBlockRule,
|
|
8
|
+
)
|
|
9
|
+
from preflight.rules.azure import (
|
|
10
|
+
AzureDatabaseDeletionReplacementRule,
|
|
11
|
+
AzureKeyVaultPurgeProtectionRule,
|
|
12
|
+
AzureNsgPublicAdminIngressRule,
|
|
13
|
+
AzureStoragePublicAccessRule,
|
|
14
|
+
)
|
|
15
|
+
from preflight.rules.base import Rule, RuleContext
|
|
16
|
+
from preflight.rules.gcp import (
|
|
17
|
+
GcpFirewallPublicAdminIngressRule,
|
|
18
|
+
GcpSqlDeletionProtectionRule,
|
|
19
|
+
GcpStoragePublicAccessRule,
|
|
20
|
+
)
|
|
21
|
+
from preflight.rules.generic import (
|
|
22
|
+
GenericDestructiveChangeRule,
|
|
23
|
+
GenericResourceDeletionRule,
|
|
24
|
+
GenericResourceReplacementRule,
|
|
25
|
+
GenericUnknownActionRule,
|
|
26
|
+
GenericUnknownValuesRule,
|
|
27
|
+
)
|
|
28
|
+
from preflight.rules.kubernetes import (
|
|
29
|
+
KubernetesClusterAdminBindingRule,
|
|
30
|
+
KubernetesWorkloadSecurityRule,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_default_rules() -> list[Rule]:
|
|
35
|
+
"""Return an instantiated list of all standard detection rules."""
|
|
36
|
+
return [
|
|
37
|
+
# Provider-neutral rules
|
|
38
|
+
GenericResourceDeletionRule(),
|
|
39
|
+
GenericResourceReplacementRule(),
|
|
40
|
+
GenericUnknownActionRule(),
|
|
41
|
+
GenericUnknownValuesRule(),
|
|
42
|
+
GenericDestructiveChangeRule(),
|
|
43
|
+
# AWS rules
|
|
44
|
+
AwsPublicAdminIngressRule(),
|
|
45
|
+
AwsS3PublicAccessBlockRule(),
|
|
46
|
+
AwsIamWildcardRule(),
|
|
47
|
+
AwsDatabaseDeletionReplacementRule(),
|
|
48
|
+
# Azure rules
|
|
49
|
+
AzureNsgPublicAdminIngressRule(),
|
|
50
|
+
AzureStoragePublicAccessRule(),
|
|
51
|
+
AzureKeyVaultPurgeProtectionRule(),
|
|
52
|
+
AzureDatabaseDeletionReplacementRule(),
|
|
53
|
+
# GCP rules
|
|
54
|
+
GcpFirewallPublicAdminIngressRule(),
|
|
55
|
+
GcpStoragePublicAccessRule(),
|
|
56
|
+
GcpSqlDeletionProtectionRule(),
|
|
57
|
+
# Kubernetes rules
|
|
58
|
+
KubernetesWorkloadSecurityRule(),
|
|
59
|
+
KubernetesClusterAdminBindingRule(),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"Rule",
|
|
65
|
+
"RuleContext",
|
|
66
|
+
"get_default_rules",
|
|
67
|
+
"GenericResourceDeletionRule",
|
|
68
|
+
"GenericResourceReplacementRule",
|
|
69
|
+
"GenericUnknownActionRule",
|
|
70
|
+
"GenericUnknownValuesRule",
|
|
71
|
+
"GenericDestructiveChangeRule",
|
|
72
|
+
"AwsPublicAdminIngressRule",
|
|
73
|
+
"AwsS3PublicAccessBlockRule",
|
|
74
|
+
"AwsIamWildcardRule",
|
|
75
|
+
"AwsDatabaseDeletionReplacementRule",
|
|
76
|
+
"AzureNsgPublicAdminIngressRule",
|
|
77
|
+
"AzureStoragePublicAccessRule",
|
|
78
|
+
"AzureKeyVaultPurgeProtectionRule",
|
|
79
|
+
"AzureDatabaseDeletionReplacementRule",
|
|
80
|
+
"GcpFirewallPublicAdminIngressRule",
|
|
81
|
+
"GcpStoragePublicAccessRule",
|
|
82
|
+
"GcpSqlDeletionProtectionRule",
|
|
83
|
+
"KubernetesWorkloadSecurityRule",
|
|
84
|
+
"KubernetesClusterAdminBindingRule",
|
|
85
|
+
]
|