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/__init__.py
ADDED
preflight/cli.py
ADDED
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Command Line Interface for Preflight."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Annotated
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
# Ensure UTF-8 output on Windows terminals
|
|
11
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
12
|
+
try:
|
|
13
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
14
|
+
except Exception:
|
|
15
|
+
pass
|
|
16
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
17
|
+
try:
|
|
18
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
19
|
+
except Exception:
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
from preflight import __version__
|
|
23
|
+
from preflight.config import ConfigError, load_config
|
|
24
|
+
from preflight.engine import PreflightEngine
|
|
25
|
+
from preflight.github import GitHubApiError, post_or_update_pr_comment, resolve_github_context
|
|
26
|
+
from preflight.models import Environment, Severity
|
|
27
|
+
from preflight.plan import PlanParseError
|
|
28
|
+
from preflight.policy import PolicyEvaluator
|
|
29
|
+
from preflight.reporting import (
|
|
30
|
+
render_json_report,
|
|
31
|
+
render_markdown_report,
|
|
32
|
+
render_pr_comment,
|
|
33
|
+
render_sarif_report,
|
|
34
|
+
render_terminal_report,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
err_console = Console(stderr=True)
|
|
38
|
+
|
|
39
|
+
MAIN_HELP = """\
|
|
40
|
+
[bold]Preflight[/bold]: Infrastructure Change Risk Analysis Engine for Terraform plans.
|
|
41
|
+
|
|
42
|
+
Analyzes Terraform execution plans to detect destructive operations, security
|
|
43
|
+
regressions, and architectural risks before changes reach production.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
MAIN_EPILOG = """\
|
|
47
|
+
[bold green]Terraform Integration Workflow:[/bold green]
|
|
48
|
+
|
|
49
|
+
[bold cyan]Workflow A: Direct Stdin Stream (Zero disk artifacts - Recommended for CI/CD)[/bold cyan]
|
|
50
|
+
$ terraform plan -out=tfplan
|
|
51
|
+
$ terraform show -json tfplan | preflight analyze - --fail-on high
|
|
52
|
+
|
|
53
|
+
[bold cyan]Workflow B: Local Plan File Analysis[/bold cyan]
|
|
54
|
+
$ terraform plan -out=tfplan
|
|
55
|
+
$ terraform show -json tfplan > plan.json
|
|
56
|
+
$ preflight analyze plan.json --environment production
|
|
57
|
+
|
|
58
|
+
[bold cyan]Workflow C: GitHub Pull Request Sticky Comment Gate[/bold cyan]
|
|
59
|
+
$ terraform show -json tfplan | preflight comment - --fail-on high
|
|
60
|
+
|
|
61
|
+
[bold cyan]Workflow D: GitHub Code Scanning & SARIF Upload[/bold cyan]
|
|
62
|
+
$ terraform show -json tfplan | preflight analyze - --format sarif --output preflight.sarif
|
|
63
|
+
|
|
64
|
+
[bold green]Supported Cloud Providers & Detection Rules:[/bold green]
|
|
65
|
+
|
|
66
|
+
[bold cyan]AWS (AWS-*)[/bold cyan]
|
|
67
|
+
• AWS-NET-001: Security group ingress allowing SSH (22), RDP (3389), or all traffic to 0.0.0.0/0
|
|
68
|
+
• AWS-S3-001: S3 Public Access Block removal or disabled settings; public bucket ACLs
|
|
69
|
+
• AWS-IAM-001: Permissive IAM wildcard policies (* on * is Critical; service-level wildcards is High)
|
|
70
|
+
• AWS-DB-001: Deletion or replacement of RDS instances, Aurora clusters, DynamoDB, or ElastiCache
|
|
71
|
+
|
|
72
|
+
[bold cyan]Azure (AZR-*)[/bold cyan]
|
|
73
|
+
• AZR-NET-001: Network Security Group (NSG) rules allowing Internet to ports 22, 3389, or wildcard *
|
|
74
|
+
• AZR-STG-001: Storage account public blob access or missing firewall restrictions
|
|
75
|
+
• AZR-KV-001: Azure Key Vault changes with purge protection disabled or soft delete retention < 7 days
|
|
76
|
+
• AZR-DB-001: Deletion or replacement of Azure SQL, Cosmos DB, or PostgreSQL flexible servers
|
|
77
|
+
|
|
78
|
+
[bold cyan]Google Cloud (GCP-*)[/bold cyan]
|
|
79
|
+
• GCP-FW-001: Ingress compute firewall rules allowing 0.0.0.0/0 to ports 22, 3389, or all protocols
|
|
80
|
+
• GCP-IAM-001: Cloud Storage bucket IAM bindings granting access to allUsers or allAuthenticatedUsers
|
|
81
|
+
• GCP-SQL-001: Cloud SQL instance deletion, replacement, or disabling deletion_protection
|
|
82
|
+
|
|
83
|
+
[bold cyan]Kubernetes & Helm (K8S-*)[/bold cyan]
|
|
84
|
+
• K8S-SEC-001: Workloads deploying with container privileged: true or hostNetwork/hostPID/hostIPC
|
|
85
|
+
• K8S-RBAC-001: RoleBindings and ClusterRoleBindings binding to superuser role cluster-admin
|
|
86
|
+
|
|
87
|
+
[bold cyan]Generic (GEN-*)[/bold cyan]
|
|
88
|
+
• GEN-DEL-001: Permanent resource deletion in Terraform plan
|
|
89
|
+
• GEN-REP-001: Resource replacement (destroy and recreate due to immutable attribute changes)
|
|
90
|
+
• GEN-DST-001: Destructive changes targeting stateful infrastructure (databases, volumes, clusters)
|
|
91
|
+
• GEN-UNK-001: Computed unknown values present in critical configuration fields
|
|
92
|
+
• GEN-ACT-001: Unrecognized or unsupported Terraform lifecycle action sequences
|
|
93
|
+
|
|
94
|
+
[bold green]CI/CD Gate Exit Codes:[/bold green]
|
|
95
|
+
|
|
96
|
+
[bold]0 (Pass)[/bold] Analysis completed successfully. No configured policy conditions were violated.
|
|
97
|
+
[bold]1 (Block)[/bold] Deployment blocked. A configured policy threshold (--fail-on) or blocked rule triggered.
|
|
98
|
+
[bold]2 (Error)[/bold] Execution error. Plan file not found, invalid JSON syntax, or bad configuration.
|
|
99
|
+
|
|
100
|
+
[bold green]Documentation & Repository:[/bold green]
|
|
101
|
+
https://github.com/adityacodes-root/Preflight
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
ANALYZE_EPILOG = """\
|
|
105
|
+
[bold green]Terraform Usage Examples & Patterns:[/bold green]
|
|
106
|
+
|
|
107
|
+
[bold cyan]1. Stream directly via pipe (recommended in CI/CD pipelines):[/bold cyan]
|
|
108
|
+
$ terraform show -json tfplan | preflight analyze -
|
|
109
|
+
|
|
110
|
+
[bold cyan]2. Block CI/CD pipeline on High or Critical severity findings:[/bold cyan]
|
|
111
|
+
$ terraform show -json tfplan | preflight analyze - --fail-on high
|
|
112
|
+
|
|
113
|
+
[bold cyan]3. Set environment criticality (development, staging, production):[/bold cyan]
|
|
114
|
+
$ preflight analyze plan.json --environment production
|
|
115
|
+
|
|
116
|
+
[bold cyan]4. Export as SARIF v2.1.0 for GitHub Code Scanning / Security Tab:[/bold cyan]
|
|
117
|
+
$ terraform show -json tfplan | preflight analyze - --format sarif --output preflight.sarif
|
|
118
|
+
|
|
119
|
+
[bold cyan]5. Export JSON report for downstream scripts or Slack / webhook bots:[/bold cyan]
|
|
120
|
+
$ preflight analyze plan.json --format json --output report.json
|
|
121
|
+
|
|
122
|
+
[bold cyan]6. Export Markdown report for pull requests or documentation:[/bold cyan]
|
|
123
|
+
$ preflight analyze plan.json --format markdown --output report.md
|
|
124
|
+
|
|
125
|
+
[bold cyan]7. Load custom organization policy file (preflight.yaml):[/bold cyan]
|
|
126
|
+
$ preflight analyze plan.json --config .preflight.yaml
|
|
127
|
+
|
|
128
|
+
[bold green]Exit Codes:[/bold green]
|
|
129
|
+
0 = Pass (Changes safe or below threshold)
|
|
130
|
+
1 = Block (Severity threshold met or blocked rule violated)
|
|
131
|
+
2 = Error (Invalid JSON, bad arguments, or file read error)
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
COMMENT_EPILOG = """\
|
|
135
|
+
[bold green]GitHub Actions CI/CD Usage:[/bold green]
|
|
136
|
+
|
|
137
|
+
[bold cyan]1. Automatic Context Resolution (Standard GitHub Actions workflow):[/bold cyan]
|
|
138
|
+
In CI, GITHUB_REPOSITORY, GITHUB_TOKEN, and PR numbers (from GITHUB_REF)
|
|
139
|
+
are detected automatically. Subsequent runs update the comment in place:
|
|
140
|
+
|
|
141
|
+
- name: Preflight PR Risk Gate
|
|
142
|
+
env:
|
|
143
|
+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
144
|
+
run: |
|
|
145
|
+
terraform show -json tfplan | preflight comment - --fail-on high
|
|
146
|
+
|
|
147
|
+
[bold cyan]2. Local Dry-Run (preview markdown without calling GitHub API):[/bold cyan]
|
|
148
|
+
$ preflight comment plan.json --dry-run --fail-on high
|
|
149
|
+
|
|
150
|
+
[bold cyan]3. Explicit Parameters (Custom runners, external PRs, or testing):[/bold cyan]
|
|
151
|
+
$ preflight comment plan.json --repo owner/repo --pr 42 --token $GITHUB_TOKEN
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
app = typer.Typer(
|
|
155
|
+
name="preflight",
|
|
156
|
+
help=MAIN_HELP,
|
|
157
|
+
epilog=MAIN_EPILOG,
|
|
158
|
+
no_args_is_help=True,
|
|
159
|
+
add_completion=False,
|
|
160
|
+
rich_markup_mode="rich",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def version_callback(value: bool) -> None:
|
|
165
|
+
if value:
|
|
166
|
+
typer.echo(f"preflight {__version__}")
|
|
167
|
+
raise typer.Exit(code=0)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@app.callback()
|
|
171
|
+
def main_callback(
|
|
172
|
+
version: Annotated[
|
|
173
|
+
bool,
|
|
174
|
+
typer.Option(
|
|
175
|
+
"--version",
|
|
176
|
+
"-V",
|
|
177
|
+
callback=version_callback,
|
|
178
|
+
is_eager=True,
|
|
179
|
+
help="Show the Preflight version and exit.",
|
|
180
|
+
),
|
|
181
|
+
] = False,
|
|
182
|
+
) -> None:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@app.command(
|
|
187
|
+
name="analyze",
|
|
188
|
+
help="Analyze a Terraform plan JSON file or stdin stream for infrastructure risk.",
|
|
189
|
+
epilog=ANALYZE_EPILOG,
|
|
190
|
+
)
|
|
191
|
+
def analyze(
|
|
192
|
+
plan_path: Annotated[
|
|
193
|
+
str,
|
|
194
|
+
typer.Argument(
|
|
195
|
+
help="Path to Terraform plan JSON file or '-' to read from stdin pipe.",
|
|
196
|
+
show_default=False,
|
|
197
|
+
),
|
|
198
|
+
],
|
|
199
|
+
environment: Annotated[
|
|
200
|
+
str | None,
|
|
201
|
+
typer.Option(
|
|
202
|
+
"--environment",
|
|
203
|
+
"-e",
|
|
204
|
+
help="Target environment (development, staging, production).",
|
|
205
|
+
),
|
|
206
|
+
] = None,
|
|
207
|
+
format_type: Annotated[
|
|
208
|
+
str | None,
|
|
209
|
+
typer.Option(
|
|
210
|
+
"--format",
|
|
211
|
+
"-f",
|
|
212
|
+
help="Output format: terminal, json, markdown, or sarif.",
|
|
213
|
+
),
|
|
214
|
+
] = None,
|
|
215
|
+
fail_on: Annotated[
|
|
216
|
+
str | None,
|
|
217
|
+
typer.Option(
|
|
218
|
+
"--fail-on",
|
|
219
|
+
help="Block deployment if findings meet or exceed this severity (low, moderate, high, critical).",
|
|
220
|
+
),
|
|
221
|
+
] = None,
|
|
222
|
+
output: Annotated[
|
|
223
|
+
Path | None,
|
|
224
|
+
typer.Option(
|
|
225
|
+
"--output",
|
|
226
|
+
"-o",
|
|
227
|
+
help="File path to save the generated report.",
|
|
228
|
+
),
|
|
229
|
+
] = None,
|
|
230
|
+
config_path: Annotated[
|
|
231
|
+
Path | None,
|
|
232
|
+
typer.Option(
|
|
233
|
+
"--config",
|
|
234
|
+
"-c",
|
|
235
|
+
help="Path to preflight configuration file (YAML).",
|
|
236
|
+
),
|
|
237
|
+
] = None,
|
|
238
|
+
verbose: Annotated[
|
|
239
|
+
bool,
|
|
240
|
+
typer.Option(
|
|
241
|
+
"--verbose",
|
|
242
|
+
"-v",
|
|
243
|
+
help="Enable verbose output.",
|
|
244
|
+
),
|
|
245
|
+
] = False,
|
|
246
|
+
) -> None:
|
|
247
|
+
"""Analyze a Terraform plan JSON file and assess deployment risk."""
|
|
248
|
+
# 1. Load configuration
|
|
249
|
+
try:
|
|
250
|
+
config = load_config(config_path)
|
|
251
|
+
except ConfigError as exc:
|
|
252
|
+
err_console.print(f"[bold red]Configuration Error:[/bold red] {exc}")
|
|
253
|
+
sys.exit(2)
|
|
254
|
+
|
|
255
|
+
# 2. Merge CLI overrides with config file values
|
|
256
|
+
effective_env_str = environment or config.environment
|
|
257
|
+
try:
|
|
258
|
+
effective_env = Environment.from_str(effective_env_str)
|
|
259
|
+
except ValueError as exc:
|
|
260
|
+
err_console.print(f"[bold red]Environment Error:[/bold red] {exc}")
|
|
261
|
+
sys.exit(2)
|
|
262
|
+
|
|
263
|
+
effective_format = (format_type or config.format).strip().lower()
|
|
264
|
+
if effective_format not in ("terminal", "json", "markdown", "sarif"):
|
|
265
|
+
err_console.print(
|
|
266
|
+
f"[bold red]Invalid Format:[/bold red] '{effective_format}'. Valid options: terminal, json, markdown, sarif."
|
|
267
|
+
)
|
|
268
|
+
sys.exit(2)
|
|
269
|
+
|
|
270
|
+
effective_fail_on_str = fail_on or config.fail_on
|
|
271
|
+
effective_fail_on: Severity | None = None
|
|
272
|
+
if effective_fail_on_str:
|
|
273
|
+
try:
|
|
274
|
+
effective_fail_on = Severity.from_str(effective_fail_on_str)
|
|
275
|
+
except ValueError as exc:
|
|
276
|
+
err_console.print(f"[bold red]Policy Error:[/bold red] {exc}")
|
|
277
|
+
sys.exit(2)
|
|
278
|
+
|
|
279
|
+
# 3. Configure policy evaluator
|
|
280
|
+
policy_evaluator = PolicyEvaluator(
|
|
281
|
+
fail_on_severity=effective_fail_on,
|
|
282
|
+
blocked_rules=config.blocked_rules,
|
|
283
|
+
allowed_rules=config.allowed_rules,
|
|
284
|
+
max_allowed_score=config.max_score,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
# 4. Run Analysis
|
|
288
|
+
engine = PreflightEngine()
|
|
289
|
+
try:
|
|
290
|
+
result = engine.analyze_file(
|
|
291
|
+
plan_path=plan_path,
|
|
292
|
+
environment=effective_env,
|
|
293
|
+
policy_evaluator=policy_evaluator,
|
|
294
|
+
)
|
|
295
|
+
except PlanParseError as exc:
|
|
296
|
+
err_console.print(f"[bold red]Plan Parse Error:[/bold red] {exc}")
|
|
297
|
+
sys.exit(2)
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
err_console.print(f"[bold red]Execution Error:[/bold red] {exc}")
|
|
300
|
+
if verbose:
|
|
301
|
+
err_console.print_exception()
|
|
302
|
+
sys.exit(2)
|
|
303
|
+
|
|
304
|
+
# 5. Render Output
|
|
305
|
+
if effective_format == "json":
|
|
306
|
+
json_output = render_json_report(result)
|
|
307
|
+
if output:
|
|
308
|
+
try:
|
|
309
|
+
output.write_text(json_output, encoding="utf-8")
|
|
310
|
+
except OSError as exc:
|
|
311
|
+
err_console.print(
|
|
312
|
+
f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
|
|
313
|
+
)
|
|
314
|
+
sys.exit(2)
|
|
315
|
+
else:
|
|
316
|
+
typer.echo(json_output)
|
|
317
|
+
|
|
318
|
+
elif effective_format == "markdown":
|
|
319
|
+
md_output = render_markdown_report(result)
|
|
320
|
+
if output:
|
|
321
|
+
try:
|
|
322
|
+
output.write_text(md_output, encoding="utf-8")
|
|
323
|
+
except OSError as exc:
|
|
324
|
+
err_console.print(
|
|
325
|
+
f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
|
|
326
|
+
)
|
|
327
|
+
sys.exit(2)
|
|
328
|
+
else:
|
|
329
|
+
typer.echo(md_output)
|
|
330
|
+
|
|
331
|
+
elif effective_format == "sarif":
|
|
332
|
+
sarif_output = render_sarif_report(result)
|
|
333
|
+
if output:
|
|
334
|
+
try:
|
|
335
|
+
output.write_text(sarif_output, encoding="utf-8")
|
|
336
|
+
except OSError as exc:
|
|
337
|
+
err_console.print(
|
|
338
|
+
f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
|
|
339
|
+
)
|
|
340
|
+
sys.exit(2)
|
|
341
|
+
else:
|
|
342
|
+
typer.echo(sarif_output)
|
|
343
|
+
|
|
344
|
+
else: # terminal
|
|
345
|
+
if output:
|
|
346
|
+
try:
|
|
347
|
+
with output.open("w", encoding="utf-8") as f:
|
|
348
|
+
render_terminal_report(result, file=f)
|
|
349
|
+
except OSError as exc:
|
|
350
|
+
err_console.print(
|
|
351
|
+
f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
|
|
352
|
+
)
|
|
353
|
+
sys.exit(2)
|
|
354
|
+
else:
|
|
355
|
+
render_terminal_report(result)
|
|
356
|
+
|
|
357
|
+
# 6. Exit code
|
|
358
|
+
if result.policy_evaluation.blocked:
|
|
359
|
+
sys.exit(1)
|
|
360
|
+
sys.exit(0)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@app.command(
|
|
364
|
+
name="comment",
|
|
365
|
+
help="Analyze a plan and publish or update a sticky GitHub Pull Request comment.",
|
|
366
|
+
epilog=COMMENT_EPILOG,
|
|
367
|
+
)
|
|
368
|
+
def comment(
|
|
369
|
+
plan_path: Annotated[
|
|
370
|
+
str,
|
|
371
|
+
typer.Argument(
|
|
372
|
+
help="Path to Terraform plan JSON file or '-' to read from stdin pipe.",
|
|
373
|
+
show_default=False,
|
|
374
|
+
),
|
|
375
|
+
],
|
|
376
|
+
pr: Annotated[
|
|
377
|
+
int | None,
|
|
378
|
+
typer.Option(
|
|
379
|
+
"--pr",
|
|
380
|
+
"-p",
|
|
381
|
+
help="Pull request number (defaults to PR_NUMBER or GITHUB_REF in CI).",
|
|
382
|
+
),
|
|
383
|
+
] = None,
|
|
384
|
+
repo: Annotated[
|
|
385
|
+
str | None,
|
|
386
|
+
typer.Option(
|
|
387
|
+
"--repo",
|
|
388
|
+
"-R",
|
|
389
|
+
help="Target repository 'owner/repo' (defaults to GITHUB_REPOSITORY).",
|
|
390
|
+
),
|
|
391
|
+
] = None,
|
|
392
|
+
token: Annotated[
|
|
393
|
+
str | None,
|
|
394
|
+
typer.Option(
|
|
395
|
+
"--token",
|
|
396
|
+
"-t",
|
|
397
|
+
help="GitHub API token (defaults to GITHUB_TOKEN or GH_TOKEN).",
|
|
398
|
+
),
|
|
399
|
+
] = None,
|
|
400
|
+
environment: Annotated[
|
|
401
|
+
str | None,
|
|
402
|
+
typer.Option(
|
|
403
|
+
"--environment",
|
|
404
|
+
"-e",
|
|
405
|
+
help="Target environment (development, staging, production).",
|
|
406
|
+
),
|
|
407
|
+
] = None,
|
|
408
|
+
fail_on: Annotated[
|
|
409
|
+
str | None,
|
|
410
|
+
typer.Option(
|
|
411
|
+
"--fail-on",
|
|
412
|
+
help="Block deployment if findings meet or exceed this severity.",
|
|
413
|
+
),
|
|
414
|
+
] = None,
|
|
415
|
+
dry_run: Annotated[
|
|
416
|
+
bool,
|
|
417
|
+
typer.Option(
|
|
418
|
+
"--dry-run",
|
|
419
|
+
help="Render the comment markdown without calling the GitHub API.",
|
|
420
|
+
),
|
|
421
|
+
] = False,
|
|
422
|
+
output: Annotated[
|
|
423
|
+
Path | None,
|
|
424
|
+
typer.Option(
|
|
425
|
+
"--output",
|
|
426
|
+
"-o",
|
|
427
|
+
help="Save the rendered comment markdown to a file.",
|
|
428
|
+
),
|
|
429
|
+
] = None,
|
|
430
|
+
config_path: Annotated[
|
|
431
|
+
Path | None,
|
|
432
|
+
typer.Option(
|
|
433
|
+
"--config",
|
|
434
|
+
"-c",
|
|
435
|
+
help="Path to preflight configuration file (YAML).",
|
|
436
|
+
),
|
|
437
|
+
] = None,
|
|
438
|
+
verbose: Annotated[
|
|
439
|
+
bool,
|
|
440
|
+
typer.Option(
|
|
441
|
+
"--verbose",
|
|
442
|
+
"-v",
|
|
443
|
+
help="Enable verbose output.",
|
|
444
|
+
),
|
|
445
|
+
] = False,
|
|
446
|
+
) -> None:
|
|
447
|
+
"""Analyze a plan and publish or update a sticky GitHub Pull Request comment."""
|
|
448
|
+
try:
|
|
449
|
+
config = load_config(config_path)
|
|
450
|
+
except ConfigError as exc:
|
|
451
|
+
err_console.print(f"[bold red]Configuration Error:[/bold red] {exc}")
|
|
452
|
+
sys.exit(2)
|
|
453
|
+
|
|
454
|
+
effective_env_str = environment or config.environment
|
|
455
|
+
try:
|
|
456
|
+
effective_env = Environment.from_str(effective_env_str)
|
|
457
|
+
except ValueError as exc:
|
|
458
|
+
err_console.print(f"[bold red]Environment Error:[/bold red] {exc}")
|
|
459
|
+
sys.exit(2)
|
|
460
|
+
|
|
461
|
+
effective_fail_on_str = fail_on or config.fail_on
|
|
462
|
+
effective_fail_on: Severity | None = None
|
|
463
|
+
if effective_fail_on_str:
|
|
464
|
+
try:
|
|
465
|
+
effective_fail_on = Severity.from_str(effective_fail_on_str)
|
|
466
|
+
except ValueError as exc:
|
|
467
|
+
err_console.print(f"[bold red]Policy Error:[/bold red] {exc}")
|
|
468
|
+
sys.exit(2)
|
|
469
|
+
|
|
470
|
+
policy_evaluator = PolicyEvaluator(
|
|
471
|
+
fail_on_severity=effective_fail_on,
|
|
472
|
+
blocked_rules=config.blocked_rules,
|
|
473
|
+
allowed_rules=config.allowed_rules,
|
|
474
|
+
max_allowed_score=config.max_score,
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
engine = PreflightEngine()
|
|
478
|
+
try:
|
|
479
|
+
result = engine.analyze_file(
|
|
480
|
+
plan_path=plan_path,
|
|
481
|
+
environment=effective_env,
|
|
482
|
+
policy_evaluator=policy_evaluator,
|
|
483
|
+
)
|
|
484
|
+
except PlanParseError as exc:
|
|
485
|
+
err_console.print(f"[bold red]Plan Parse Error:[/bold red] {exc}")
|
|
486
|
+
sys.exit(2)
|
|
487
|
+
except Exception as exc:
|
|
488
|
+
err_console.print(f"[bold red]Execution Error:[/bold red] {exc}")
|
|
489
|
+
if verbose:
|
|
490
|
+
err_console.print_exception()
|
|
491
|
+
sys.exit(2)
|
|
492
|
+
|
|
493
|
+
comment_body = render_pr_comment(result)
|
|
494
|
+
|
|
495
|
+
if output:
|
|
496
|
+
try:
|
|
497
|
+
output.write_text(comment_body, encoding="utf-8")
|
|
498
|
+
except OSError as exc:
|
|
499
|
+
err_console.print(
|
|
500
|
+
f"[bold red]Output Error:[/bold red] Failed to write to '{output}': {exc}"
|
|
501
|
+
)
|
|
502
|
+
sys.exit(2)
|
|
503
|
+
|
|
504
|
+
if dry_run:
|
|
505
|
+
typer.echo(comment_body)
|
|
506
|
+
else:
|
|
507
|
+
try:
|
|
508
|
+
resolved_repo, resolved_pr, resolved_token = resolve_github_context(
|
|
509
|
+
repo=repo, pr=pr, token=token
|
|
510
|
+
)
|
|
511
|
+
resp = post_or_update_pr_comment(
|
|
512
|
+
repo=resolved_repo,
|
|
513
|
+
pr_number=resolved_pr,
|
|
514
|
+
token=resolved_token,
|
|
515
|
+
comment_body=comment_body,
|
|
516
|
+
)
|
|
517
|
+
action = resp.get("action", "posted")
|
|
518
|
+
url = resp.get("url", "")
|
|
519
|
+
err_console.print(
|
|
520
|
+
f"[bold green]PR Comment {action.capitalize()}:[/bold green] {resolved_repo}#{resolved_pr} {url}".strip()
|
|
521
|
+
)
|
|
522
|
+
except GitHubApiError as exc:
|
|
523
|
+
err_console.print(f"[bold red]GitHub Comment Error:[/bold red] {exc}")
|
|
524
|
+
sys.exit(2)
|
|
525
|
+
|
|
526
|
+
if result.policy_evaluation.blocked:
|
|
527
|
+
sys.exit(1)
|
|
528
|
+
sys.exit(0)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def main() -> None:
|
|
532
|
+
app()
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
main()
|
preflight/config.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Configuration management for Preflight."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from pydantic import BaseModel, Field, field_validator
|
|
7
|
+
|
|
8
|
+
from preflight.models import Environment, Severity
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_FILENAMES = [
|
|
11
|
+
"preflight.yaml",
|
|
12
|
+
"preflight.yml",
|
|
13
|
+
".preflight.yaml",
|
|
14
|
+
".preflight.yml",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ConfigError(Exception):
|
|
19
|
+
"""Raised when configuration cannot be loaded or is invalid."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class PreflightConfig(BaseModel):
|
|
23
|
+
environment: str = Field(default="production", description="Target environment context")
|
|
24
|
+
fail_on: str | None = Field(default=None, description="Minimum severity to block deployment")
|
|
25
|
+
blocked_rules: list[str] = Field(
|
|
26
|
+
default_factory=list, description="Rules that unconditionally block"
|
|
27
|
+
)
|
|
28
|
+
allowed_rules: list[str] = Field(
|
|
29
|
+
default_factory=list, description="Rules to ignore or suppress"
|
|
30
|
+
)
|
|
31
|
+
max_score: float | None = Field(default=None, description="Maximum allowed plan risk score")
|
|
32
|
+
format: str = Field(default="terminal", description="Output report format")
|
|
33
|
+
verbose: bool = Field(default=False, description="Enable detailed output")
|
|
34
|
+
|
|
35
|
+
@field_validator("environment")
|
|
36
|
+
@classmethod
|
|
37
|
+
def validate_environment(cls, v: str) -> str:
|
|
38
|
+
try:
|
|
39
|
+
Environment.from_str(v)
|
|
40
|
+
return v.lower()
|
|
41
|
+
except ValueError as exc:
|
|
42
|
+
raise ValueError(str(exc)) from exc
|
|
43
|
+
|
|
44
|
+
@field_validator("fail_on")
|
|
45
|
+
@classmethod
|
|
46
|
+
def validate_fail_on(cls, v: str | None) -> str | None:
|
|
47
|
+
if v is None:
|
|
48
|
+
return None
|
|
49
|
+
try:
|
|
50
|
+
Severity.from_str(v)
|
|
51
|
+
return v.lower()
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise ValueError(str(exc)) from exc
|
|
54
|
+
|
|
55
|
+
@field_validator("format")
|
|
56
|
+
@classmethod
|
|
57
|
+
def validate_format(cls, v: str) -> str:
|
|
58
|
+
normalized = v.strip().lower()
|
|
59
|
+
if normalized not in ("terminal", "json", "markdown", "sarif"):
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"Invalid format '{v}'. Valid options: terminal, json, markdown, sarif."
|
|
62
|
+
)
|
|
63
|
+
return normalized
|
|
64
|
+
|
|
65
|
+
@field_validator("max_score")
|
|
66
|
+
@classmethod
|
|
67
|
+
def validate_max_score(cls, v: float | None) -> float | None:
|
|
68
|
+
if v is not None and (v < 0.0 or v > 100.0):
|
|
69
|
+
raise ValueError("max_score must be between 0.0 and 100.0.")
|
|
70
|
+
return v
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def load_config(config_path: str | Path | None = None) -> PreflightConfig:
|
|
74
|
+
"""Load and validate configuration from a file or discover the default."""
|
|
75
|
+
target_path: Path | None = None
|
|
76
|
+
|
|
77
|
+
if config_path is not None:
|
|
78
|
+
target_path = Path(config_path)
|
|
79
|
+
if not target_path.exists():
|
|
80
|
+
raise ConfigError(f"Config file not found: '{target_path}'.")
|
|
81
|
+
else:
|
|
82
|
+
for filename in DEFAULT_CONFIG_FILENAMES:
|
|
83
|
+
candidate = Path(filename)
|
|
84
|
+
if candidate.exists() and candidate.is_file():
|
|
85
|
+
target_path = candidate
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
if target_path is None:
|
|
89
|
+
return PreflightConfig()
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
content = target_path.read_text(encoding="utf-8")
|
|
93
|
+
except OSError as exc:
|
|
94
|
+
raise ConfigError(f"Failed to read config file '{target_path}': {exc}") from exc
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
raw_data = yaml.safe_load(content) or {}
|
|
98
|
+
except yaml.YAMLError as exc:
|
|
99
|
+
raise ConfigError(f"Invalid YAML syntax in '{target_path}': {exc}") from exc
|
|
100
|
+
|
|
101
|
+
if not isinstance(raw_data, dict):
|
|
102
|
+
raise ConfigError(f"Configuration in '{target_path}' must be a key-value mapping.")
|
|
103
|
+
|
|
104
|
+
try:
|
|
105
|
+
return PreflightConfig(**raw_data)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise ConfigError(f"Configuration validation error in '{target_path}': {exc}") from exc
|