driftsentry 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.
Files changed (52) hide show
  1. driftsentry/__init__.py +8 -0
  2. driftsentry/attribution/__init__.py +1 -0
  3. driftsentry/attribution/cloudtrail.py +260 -0
  4. driftsentry/cli/__init__.py +1 -0
  5. driftsentry/cli/main.py +54 -0
  6. driftsentry/cli/remediate.py +195 -0
  7. driftsentry/cli/report.py +113 -0
  8. driftsentry/cli/scan.py +218 -0
  9. driftsentry/core/__init__.py +1 -0
  10. driftsentry/core/config.py +214 -0
  11. driftsentry/core/differ.py +286 -0
  12. driftsentry/core/models.py +257 -0
  13. driftsentry/core/scanner.py +321 -0
  14. driftsentry/notifications/__init__.py +1 -0
  15. driftsentry/notifications/slack.py +109 -0
  16. driftsentry/notifications/stdout.py +27 -0
  17. driftsentry/output/__init__.py +8 -0
  18. driftsentry/output/html.py +277 -0
  19. driftsentry/output/json_fmt.py +39 -0
  20. driftsentry/output/markdown.py +120 -0
  21. driftsentry/output/table.py +196 -0
  22. driftsentry/policy/__init__.py +1 -0
  23. driftsentry/policy/engine.py +160 -0
  24. driftsentry/policy/rules.py +112 -0
  25. driftsentry/providers/__init__.py +5 -0
  26. driftsentry/providers/aws/__init__.py +5 -0
  27. driftsentry/providers/aws/mapping.py +216 -0
  28. driftsentry/providers/aws/provider.py +134 -0
  29. driftsentry/providers/aws/resources/__init__.py +17 -0
  30. driftsentry/providers/aws/resources/ec2.py +301 -0
  31. driftsentry/providers/aws/resources/ecs.py +166 -0
  32. driftsentry/providers/aws/resources/iam.py +214 -0
  33. driftsentry/providers/aws/resources/lambda_fn.py +85 -0
  34. driftsentry/providers/aws/resources/rds.py +97 -0
  35. driftsentry/providers/aws/resources/s3.py +153 -0
  36. driftsentry/providers/base.py +110 -0
  37. driftsentry/remediation/__init__.py +1 -0
  38. driftsentry/remediation/generator.py +312 -0
  39. driftsentry/remediation/pr_creator.py +124 -0
  40. driftsentry/remediation/templates/import_block.tf.j2 +5 -0
  41. driftsentry/remediation/templates/pr_body.md.j2 +22 -0
  42. driftsentry/remediation/templates/resource_block.tf.j2 +6 -0
  43. driftsentry/state/__init__.py +13 -0
  44. driftsentry/state/base.py +51 -0
  45. driftsentry/state/factory.py +48 -0
  46. driftsentry/state/local.py +137 -0
  47. driftsentry/state/s3.py +109 -0
  48. driftsentry-0.1.0.dist-info/METADATA +502 -0
  49. driftsentry-0.1.0.dist-info/RECORD +52 -0
  50. driftsentry-0.1.0.dist-info/WHEEL +4 -0
  51. driftsentry-0.1.0.dist-info/entry_points.txt +2 -0
  52. driftsentry-0.1.0.dist-info/licenses/LICENSE +190 -0
@@ -0,0 +1,8 @@
1
+ """DriftSentry — Your infrastructure's immune system.
2
+
3
+ Detect IaC drift, attribute blame, auto-remediate with PRs.
4
+ Supports Terraform and OpenTofu.
5
+ """
6
+
7
+ __version__ = "0.1.0"
8
+ __app_name__ = "driftsentry"
@@ -0,0 +1 @@
1
+ """Drift attribution — attribute who caused drift using cloud audit logs."""
@@ -0,0 +1,260 @@
1
+ """CloudTrail attributor — queries AWS CloudTrail to determine who caused drift.
2
+
3
+ Looks up recent CloudTrail events for drifted resources to identify
4
+ the IAM principal, API call, timestamp, and source IP.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime
10
+ import logging
11
+ from typing import Any
12
+
13
+ import boto3
14
+ from botocore.exceptions import ClientError
15
+
16
+ from driftsentry.core.models import DriftAttribution, DriftItem
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+ # ─── Resource type to CloudTrail event mapping ──────────────────
21
+
22
+ # Maps Terraform resource types to the CloudTrail event names that
23
+ # could modify them. This is a best-effort mapping.
24
+ RESOURCE_EVENT_MAP: dict[str, list[str]] = {
25
+ "aws_instance": [
26
+ "ModifyInstanceAttribute",
27
+ "RunInstances",
28
+ "TerminateInstances",
29
+ "StopInstances",
30
+ "StartInstances",
31
+ ],
32
+ "aws_security_group": [
33
+ "AuthorizeSecurityGroupIngress",
34
+ "AuthorizeSecurityGroupEgress",
35
+ "RevokeSecurityGroupIngress",
36
+ "RevokeSecurityGroupEgress",
37
+ "ModifySecurityGroupRules",
38
+ "UpdateSecurityGroupRuleDescriptionsIngress",
39
+ "UpdateSecurityGroupRuleDescriptionsEgress",
40
+ "CreateSecurityGroup",
41
+ "DeleteSecurityGroup",
42
+ ],
43
+ "aws_s3_bucket": [
44
+ "PutBucketPolicy",
45
+ "DeleteBucketPolicy",
46
+ "PutBucketAcl",
47
+ "PutBucketVersioning",
48
+ "PutBucketEncryption",
49
+ "DeleteBucketEncryption",
50
+ "PutBucketLogging",
51
+ "PutPublicAccessBlock",
52
+ "DeletePublicAccessBlock",
53
+ ],
54
+ "aws_iam_role": [
55
+ "UpdateAssumeRolePolicy",
56
+ "AttachRolePolicy",
57
+ "DetachRolePolicy",
58
+ "PutRolePolicy",
59
+ "DeleteRolePolicy",
60
+ "CreateRole",
61
+ "DeleteRole",
62
+ ],
63
+ "aws_iam_policy": [
64
+ "CreatePolicyVersion",
65
+ "SetDefaultPolicyVersion",
66
+ "DeletePolicyVersion",
67
+ "CreatePolicy",
68
+ "DeletePolicy",
69
+ ],
70
+ "aws_iam_user": [
71
+ "CreateUser",
72
+ "DeleteUser",
73
+ "AttachUserPolicy",
74
+ "DetachUserPolicy",
75
+ ],
76
+ "aws_db_instance": [
77
+ "ModifyDBInstance",
78
+ "DeleteDBInstance",
79
+ "CreateDBInstance",
80
+ ],
81
+ "aws_lambda_function": [
82
+ "UpdateFunctionConfiguration",
83
+ "UpdateFunctionCode",
84
+ "CreateFunction",
85
+ "DeleteFunction",
86
+ "PublishVersion",
87
+ ],
88
+ "aws_ecs_cluster": [
89
+ "CreateCluster",
90
+ "DeleteCluster",
91
+ "UpdateClusterSettings",
92
+ ],
93
+ "aws_ecs_service": [
94
+ "CreateService",
95
+ "UpdateService",
96
+ "DeleteService",
97
+ ],
98
+ }
99
+
100
+ # Console user agents
101
+ CONSOLE_USER_AGENTS = [
102
+ "console.amazonaws.com",
103
+ "signin.amazonaws.com",
104
+ "Coral/Jakarta",
105
+ "Coral/Netty4",
106
+ ]
107
+
108
+
109
+ class CloudTrailAttributor:
110
+ """Queries CloudTrail to attribute drift to specific IAM principals.
111
+
112
+ Uses CloudTrail's `lookup_events` API with resource-based filtering
113
+ to find who made changes to drifted resources.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ region: str = "us-east-1",
119
+ profile: str | None = None,
120
+ lookback_hours: int = 168, # 7 days
121
+ ) -> None:
122
+ session_kwargs: dict[str, Any] = {"region_name": region}
123
+ if profile:
124
+ session_kwargs["profile_name"] = profile
125
+
126
+ session = boto3.Session(**session_kwargs)
127
+ self._cloudtrail = session.client("cloudtrail")
128
+ self._lookback_hours = lookback_hours
129
+
130
+ def attribute(self, drift_item: DriftItem) -> DriftAttribution | None:
131
+ """Find who caused drift for a given drift item.
132
+
133
+ Returns the most recent relevant CloudTrail event, or None
134
+ if no attribution could be made.
135
+ """
136
+ resource_id = drift_item.resource_id
137
+ resource_type = drift_item.resource_type
138
+
139
+ if not resource_id:
140
+ return None
141
+
142
+ # Get relevant event names for this resource type
143
+ event_names = RESOURCE_EVENT_MAP.get(resource_type, [])
144
+ if not event_names:
145
+ logger.debug(f"No event mapping for {resource_type}")
146
+ return None
147
+
148
+ # Query CloudTrail
149
+ events = self._lookup_events(resource_id, event_names)
150
+
151
+ if not events:
152
+ return None
153
+
154
+ # Return the most recent relevant event
155
+ event = events[0]
156
+ user_agent = event.get("userAgent", "")
157
+
158
+ return DriftAttribution(
159
+ principal=self._extract_principal(event),
160
+ event_name=event.get("eventName"),
161
+ event_time=event.get("eventTime"),
162
+ source_ip=event.get("sourceIPAddress"),
163
+ user_agent=user_agent,
164
+ is_console_change=any(ua in user_agent for ua in CONSOLE_USER_AGENTS),
165
+ )
166
+
167
+ def _lookup_events(
168
+ self,
169
+ resource_id: str,
170
+ event_names: list[str],
171
+ ) -> list[dict[str, Any]]:
172
+ """Query CloudTrail for events related to a resource."""
173
+ start_time = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(
174
+ hours=self._lookback_hours
175
+ )
176
+
177
+ all_events: list[dict[str, Any]] = []
178
+
179
+ try:
180
+ # Look up by resource name/ID
181
+ paginator = self._cloudtrail.get_paginator("lookup_events")
182
+ for page in paginator.paginate(
183
+ LookupAttributes=[
184
+ {
185
+ "AttributeKey": "ResourceName",
186
+ "AttributeValue": resource_id,
187
+ }
188
+ ],
189
+ StartTime=start_time,
190
+ MaxResults=50,
191
+ ):
192
+ for event in page.get("Events", []):
193
+ if event.get("EventName") in event_names:
194
+ all_events.append(self._parse_event(event))
195
+
196
+ except ClientError as e:
197
+ logger.warning(f"CloudTrail lookup failed for {resource_id}: {e}")
198
+ return []
199
+
200
+ # Sort by time, most recent first
201
+ all_events.sort(key=lambda e: e.get("eventTime", ""), reverse=True)
202
+ return all_events
203
+
204
+ @staticmethod
205
+ def _parse_event(event: dict[str, Any]) -> dict[str, Any]:
206
+ """Parse a CloudTrail event into a standardized dict."""
207
+ import json
208
+
209
+ cloud_trail_event = event.get("CloudTrailEvent", "{}")
210
+ if isinstance(cloud_trail_event, str):
211
+ try:
212
+ detail = json.loads(cloud_trail_event)
213
+ except json.JSONDecodeError:
214
+ detail = {}
215
+ else:
216
+ detail = cloud_trail_event
217
+
218
+ return {
219
+ "eventName": event.get("EventName") or detail.get("eventName"),
220
+ "eventTime": event.get("EventTime"),
221
+ "sourceIPAddress": detail.get("sourceIPAddress"),
222
+ "userAgent": detail.get("userAgent", ""),
223
+ "userIdentity": detail.get("userIdentity", {}),
224
+ "requestParameters": detail.get("requestParameters", {}),
225
+ }
226
+
227
+ @staticmethod
228
+ def _extract_principal(event: dict[str, Any]) -> str:
229
+ """Extract a human-readable principal name from CloudTrail event."""
230
+ identity = event.get("userIdentity", {})
231
+
232
+ # Check for assumed role with session name
233
+ session_context = identity.get("sessionContext", {})
234
+ session_issuer = session_context.get("sessionIssuer", {})
235
+
236
+ arn = identity.get("arn", "")
237
+
238
+ # If it's an assumed role, show the role name and session name
239
+ if identity.get("type") == "AssumedRole":
240
+ role_name = session_issuer.get("userName", "")
241
+ # ARN format: arn:aws:sts::123456:assumed-role/RoleName/SessionName
242
+ parts = arn.split("/")
243
+ session_name = parts[-1] if len(parts) > 2 else ""
244
+ if session_name and role_name:
245
+ return f"{role_name}/{session_name}"
246
+ return str(role_name or arn)
247
+
248
+ # IAM user
249
+ if identity.get("type") == "IAMUser":
250
+ return str(identity.get("userName") or arn)
251
+
252
+ # Root account
253
+ if identity.get("type") == "Root":
254
+ return "root"
255
+
256
+ # AWS service
257
+ if identity.get("type") == "AWSService":
258
+ return str(identity.get("invokedBy") or "aws-service")
259
+
260
+ return str(arn or "unknown")
@@ -0,0 +1 @@
1
+ """CLI package for DriftSentry."""
@@ -0,0 +1,54 @@
1
+ """DriftSentry CLI — main entry point.
2
+
3
+ Usage:
4
+ driftsentry scan --state-file terraform.tfstate
5
+ driftsentry report --format html --output drift-report.html
6
+ driftsentry remediate --mode both --create-pr
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import typer
12
+ from rich.console import Console
13
+
14
+ from driftsentry import __version__
15
+ from driftsentry.cli.remediate import remediate
16
+ from driftsentry.cli.report import report
17
+ from driftsentry.cli.scan import scan
18
+
19
+ console = Console()
20
+
21
+ app = typer.Typer(
22
+ name="driftsentry",
23
+ help=(
24
+ "🛡️ DriftSentry — Your infrastructure's immune system.\n\n"
25
+ "Detect IaC drift, attribute blame, auto-remediate with PRs.\n"
26
+ "Supports Terraform and OpenTofu."
27
+ ),
28
+ add_completion=True,
29
+ no_args_is_help=True,
30
+ rich_markup_mode="rich",
31
+ )
32
+
33
+ # Register sub-commands
34
+ app.command(name="scan", help="Scan for infrastructure drift")(scan)
35
+ app.command(name="report", help="Generate a drift report from the last scan")(report)
36
+ app.command(name="remediate", help="Generate remediation artifacts and optionally create a PR")(
37
+ remediate
38
+ )
39
+
40
+
41
+ @app.command()
42
+ def version() -> None:
43
+ """Show DriftSentry version."""
44
+ console.print(f"[bold cyan]DriftSentry[/] v{__version__}")
45
+
46
+
47
+ @app.callback()
48
+ def main_callback() -> None:
49
+ """DriftSentry — IaC Drift Detection & Auto-Remediation Engine."""
50
+ pass
51
+
52
+
53
+ if __name__ == "__main__":
54
+ app()
@@ -0,0 +1,195 @@
1
+ """Remediate command — generate remediation artifacts and optionally create PRs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from driftsentry.cli.scan import get_last_scan_result
12
+ from driftsentry.core.models import DriftResult, IaCTool, RemediationMode
13
+ from driftsentry.remediation.generator import RemediationGenerator, RemediationOutput
14
+
15
+ console = Console()
16
+
17
+
18
+ def remediate(
19
+ input_file: str | None = typer.Option(
20
+ None,
21
+ "--input",
22
+ "-i",
23
+ help="Path to a saved scan result JSON file",
24
+ ),
25
+ mode: str = typer.Option(
26
+ "both",
27
+ "--mode",
28
+ "-m",
29
+ help="Remediation mode: import, revert, both",
30
+ ),
31
+ output_dir: str = typer.Option(
32
+ "./driftsentry-remediation",
33
+ "--output-dir",
34
+ "-o",
35
+ help="Directory to write remediation artifacts",
36
+ ),
37
+ iac_tool: str = typer.Option(
38
+ "terraform",
39
+ "--iac-tool",
40
+ help="IaC tool: terraform, opentofu",
41
+ ),
42
+ create_pr: bool = typer.Option(
43
+ False,
44
+ "--create-pr",
45
+ help="Create a GitHub PR with remediation code",
46
+ ),
47
+ github_repo: str | None = typer.Option(
48
+ None,
49
+ "--repo",
50
+ help="GitHub repo (owner/name) for PR creation",
51
+ ),
52
+ github_token: str | None = typer.Option(
53
+ None,
54
+ "--github-token",
55
+ help="GitHub token (or set GITHUB_TOKEN env var)",
56
+ envvar="GITHUB_TOKEN",
57
+ ),
58
+ base_branch: str = typer.Option(
59
+ "main",
60
+ "--base-branch",
61
+ help="Base branch for PRs",
62
+ ),
63
+ dry_run: bool = typer.Option(
64
+ False,
65
+ "--dry-run",
66
+ help="Preview remediation without writing files",
67
+ ),
68
+ config_file: str | None = typer.Option(
69
+ None,
70
+ "--config",
71
+ "-c",
72
+ help="Path to .driftsentry.yaml config file",
73
+ ),
74
+ ) -> None:
75
+ """Generate remediation artifacts for drifted resources.
76
+
77
+ Supports three modes:
78
+ - import: Generate terraform import commands for unmanaged resources
79
+ - revert: Generate revert plan for changed resources
80
+ - both: Generate both (default, configurable per-resource)
81
+
82
+ Examples:
83
+
84
+ driftsentry remediate --input scan-result.json --mode both
85
+
86
+ driftsentry remediate --mode import --iac-tool opentofu
87
+
88
+ driftsentry remediate --create-pr --repo myorg/infra
89
+ """
90
+ # Load result
91
+ result = _load_result(input_file)
92
+ if result is None:
93
+ console.print(
94
+ "[bold red]Error:[/] No scan result available. "
95
+ "Run [bold]driftsentry scan[/] first or use [bold]--input[/]."
96
+ )
97
+ raise typer.Exit(code=1)
98
+
99
+ if not result.has_drift:
100
+ console.print("[bold green]✅ No drift to remediate![/]")
101
+ return
102
+
103
+ # Generate remediation
104
+ generator = RemediationGenerator(
105
+ mode=RemediationMode(mode),
106
+ iac_tool=IaCTool(iac_tool),
107
+ output_dir=output_dir,
108
+ dry_run=dry_run,
109
+ )
110
+
111
+ output = generator.generate(result)
112
+
113
+ # Display summary
114
+ if dry_run:
115
+ console.print("[bold yellow]🏃 Dry run — no files written[/]\n")
116
+
117
+ if output.import_commands:
118
+ console.print(f"[bold]📥 Import commands:[/] {len(output.import_commands)}")
119
+ for cmd in output.import_commands[:5]:
120
+ console.print(f" [dim]$ {cmd}[/]")
121
+ if len(output.import_commands) > 5:
122
+ console.print(f" [dim]... and {len(output.import_commands) - 5} more[/]")
123
+ console.print()
124
+
125
+ if output.revert_items:
126
+ console.print(f"[bold]🔄 Revert items:[/] {len(output.revert_items)}")
127
+ for item in output.revert_items[:5]:
128
+ console.print(f" [dim]{item['resource']} ({len(item['changes'])} changes)[/]")
129
+ console.print()
130
+
131
+ if output.deleted_resources:
132
+ console.print(f"[bold]🗑️ Deleted resources:[/] {len(output.deleted_resources)}")
133
+ for addr in output.deleted_resources[:5]:
134
+ console.print(f" [dim]{addr}[/]")
135
+ console.print()
136
+
137
+ if not dry_run:
138
+ console.print(f"[green]✅ Remediation artifacts written to {output_dir}/[/]")
139
+ for f in sorted(output.files_created):
140
+ console.print(f" [dim]• {f}[/]")
141
+
142
+ # Create PR if requested
143
+ if create_pr and not dry_run:
144
+ _create_pr(result, output, github_repo, github_token, base_branch)
145
+
146
+
147
+ def _create_pr(
148
+ result: DriftResult,
149
+ remediation_output: RemediationOutput,
150
+ repo: str | None,
151
+ token: str | None,
152
+ base_branch: str,
153
+ ) -> None:
154
+ """Create a GitHub PR with remediation code."""
155
+ if not repo:
156
+ console.print("[bold red]Error:[/] --repo is required for PR creation")
157
+ raise typer.Exit(code=1)
158
+
159
+ if not token:
160
+ console.print(
161
+ "[bold red]Error:[/] GitHub token required. "
162
+ "Use --github-token or set GITHUB_TOKEN env var."
163
+ )
164
+ raise typer.Exit(code=1)
165
+
166
+ try:
167
+ from driftsentry.remediation.pr_creator import PRCreator
168
+
169
+ creator = PRCreator(
170
+ github_token=token,
171
+ repo=repo,
172
+ base_branch=base_branch,
173
+ )
174
+
175
+ pr_url = creator.create_pr(result, remediation_output)
176
+ console.print(f"\n[bold green]✅ Created PR:[/] {pr_url}")
177
+ except ImportError:
178
+ console.print("[bold red]Error:[/] PyGithub not installed. Run: pip install pygithub")
179
+ raise typer.Exit(code=1) from None
180
+ except Exception as e:
181
+ console.print(f"[bold red]Error creating PR:[/] {e}")
182
+ raise typer.Exit(code=1) from None
183
+
184
+
185
+ def _load_result(input_file: str | None) -> DriftResult | None:
186
+ """Load a DriftResult from a file or the last scan."""
187
+ if input_file:
188
+ path = Path(input_file)
189
+ if not path.exists():
190
+ console.print(f"[bold red]Error not found:[/] {input_file}")
191
+ raise typer.Exit(code=1)
192
+ data = json.loads(path.read_text())
193
+ return DriftResult(**data)
194
+
195
+ return get_last_scan_result()
@@ -0,0 +1,113 @@
1
+ """Report command — generate drift reports in various formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ from rich.console import Console
10
+
11
+ from driftsentry.cli.scan import get_last_scan_result
12
+ from driftsentry.core.models import DriftResult
13
+ from driftsentry.output.html import HTMLFormatter
14
+ from driftsentry.output.json_fmt import JSONFormatter
15
+ from driftsentry.output.markdown import MarkdownFormatter
16
+ from driftsentry.output.table import TableFormatter
17
+
18
+ console = Console()
19
+
20
+
21
+ def report(
22
+ input_file: str | None = typer.Option(
23
+ None,
24
+ "--input",
25
+ "-i",
26
+ help="Path to a saved scan result JSON file (from driftsentry scan --save)",
27
+ ),
28
+ output_format: str = typer.Option(
29
+ "table",
30
+ "--format",
31
+ "-f",
32
+ help="Output format: table, json, html, markdown",
33
+ ),
34
+ output_file: str | None = typer.Option(
35
+ None,
36
+ "--output",
37
+ "-o",
38
+ help="Path to write the report file (required for html/markdown)",
39
+ ),
40
+ verbose: bool = typer.Option(
41
+ False,
42
+ "--verbose",
43
+ "-v",
44
+ help="Show detailed attribute diffs",
45
+ ),
46
+ ) -> None:
47
+ """Generate a drift report from a saved scan result or the last scan.
48
+
49
+ Examples:
50
+
51
+ driftsentry report --format html --output drift-report.html
52
+
53
+ driftsentry report --input scan-result.json --format markdown --output report.md
54
+
55
+ driftsentry report --format json
56
+ """
57
+ # Load the result
58
+ result = _load_result(input_file)
59
+ if result is None:
60
+ console.print(
61
+ "[bold red]Error:[/] No scan result available. "
62
+ "Run [bold]driftsentry scan[/] first or use [bold]--input[/] "
63
+ "to load a saved result."
64
+ )
65
+ raise typer.Exit(code=1)
66
+
67
+ # Generate report
68
+ if output_format == "table":
69
+ formatter = TableFormatter(console=console, verbose=verbose)
70
+ formatter.render(result)
71
+
72
+ elif output_format == "json":
73
+ formatter_json = JSONFormatter(pretty=True)
74
+ if output_file:
75
+ with open(output_file, "w") as f:
76
+ formatter_json.render(result, output=f)
77
+ console.print(f"[green]✅ JSON report written to {output_file}[/]")
78
+ else:
79
+ formatter_json.render(result)
80
+
81
+ elif output_format == "html":
82
+ if not output_file:
83
+ output_file = "driftsentry-report.html"
84
+ formatter_html = HTMLFormatter()
85
+ formatter_html.render(result, output_path=output_file)
86
+ console.print(f"[green]✅ HTML report written to {output_file}[/]")
87
+
88
+ elif output_format == "markdown":
89
+ formatter_md = MarkdownFormatter()
90
+ if output_file:
91
+ formatter_md.render(result, output_path=output_file)
92
+ console.print(f"[green]✅ Markdown report written to {output_file}[/]")
93
+ else:
94
+ md = formatter_md.render(result)
95
+ console.print(md)
96
+
97
+ else:
98
+ console.print(f"[bold red]Error:[/] Unknown format: {output_format}")
99
+ raise typer.Exit(code=1)
100
+
101
+
102
+ def _load_result(input_file: str | None) -> DriftResult | None:
103
+ """Load a DriftResult from a file or the last scan."""
104
+ if input_file:
105
+ path = Path(input_file)
106
+ if not path.exists():
107
+ console.print(f"[bold red]Error:[/] File not found: {input_file}")
108
+ raise typer.Exit(code=1)
109
+
110
+ data = json.loads(path.read_text())
111
+ return DriftResult(**data)
112
+
113
+ return get_last_scan_result()