drifty 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.
drifty/.DS_Store ADDED
Binary file
drifty/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """drifty — Terraform Drift Intelligence."""
2
+
3
+ __version__ = "0.1.0"
4
+ __author__ = "Satyajit Dey"
5
+ __email__ = "satyajit.dey@umbc.edu"
drifty/cli.py ADDED
@@ -0,0 +1,313 @@
1
+ """
2
+ drifty — Terraform Drift Intelligence
3
+ Entry point for all CLI commands via Typer.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from pathlib import Path
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from drifty import __version__
14
+ from drifty.config import (
15
+ init_workspace,
16
+ set_config_value,
17
+ show_config,
18
+ )
19
+
20
+ app = typer.Typer(
21
+ name="drifty",
22
+ help="Terraform drift intelligence: detect, attribute, score, and fix IaC drift.",
23
+ add_completion=True,
24
+ rich_markup_mode="rich",
25
+ pretty_exceptions_show_locals=False,
26
+ )
27
+
28
+ config_app = typer.Typer(
29
+ name="config",
30
+ help="Manage drifty configuration.",
31
+ rich_markup_mode="rich",
32
+ )
33
+ app.add_typer(config_app, name="config")
34
+
35
+ console = Console()
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Version callback
40
+ # ---------------------------------------------------------------------------
41
+
42
+
43
+ def version_callback(value: bool) -> None:
44
+ if value:
45
+ console.print(f"[bold cyan]drifty[/bold cyan] version [green]{__version__}[/green]")
46
+ raise typer.Exit()
47
+
48
+
49
+ # ---------------------------------------------------------------------------
50
+ # Root options
51
+ # ---------------------------------------------------------------------------
52
+
53
+
54
+ @app.callback()
55
+ def main(
56
+ version: bool | None = typer.Option(
57
+ None,
58
+ "--version",
59
+ "-v",
60
+ help="Show drifty version and exit.",
61
+ callback=version_callback,
62
+ is_eager=True,
63
+ ),
64
+ ) -> None:
65
+ """
66
+ [bold cyan]drifty[/bold cyan] — Terraform Drift Intelligence
67
+
68
+ Detect what drifted, who changed it, how dangerous it is, and how to fix it.
69
+ """
70
+
71
+
72
+ # ---------------------------------------------------------------------------
73
+ # drifty init
74
+ # ---------------------------------------------------------------------------
75
+
76
+
77
+ @app.command("init")
78
+ def cmd_init(
79
+ workspace: Path = typer.Option(
80
+ Path("."),
81
+ "--workspace",
82
+ "-w",
83
+ help="Path to Terraform workspace directory.",
84
+ exists=True,
85
+ file_okay=False,
86
+ dir_okay=True,
87
+ resolve_path=True,
88
+ ),
89
+ ) -> None:
90
+ """
91
+ Initialize a [bold].drifty/config.yaml[/bold] in the current workspace.
92
+ """
93
+ init_workspace(workspace)
94
+
95
+
96
+ # ---------------------------------------------------------------------------
97
+ # drifty scan
98
+ # ---------------------------------------------------------------------------
99
+
100
+
101
+ @app.command("scan")
102
+ def cmd_scan(
103
+ workspace: Path = typer.Option(
104
+ Path("."),
105
+ "--workspace",
106
+ "-w",
107
+ help="Path to Terraform workspace directory.",
108
+ exists=True,
109
+ file_okay=False,
110
+ dir_okay=True,
111
+ resolve_path=True,
112
+ ),
113
+ profile: str = typer.Option(
114
+ "default",
115
+ "--profile",
116
+ "-p",
117
+ help="AWS CLI profile to use for CloudTrail lookups.",
118
+ ),
119
+ attribute: bool = typer.Option(
120
+ False,
121
+ "--attribute",
122
+ "-a",
123
+ help="Enable CloudTrail attribution (who caused each drift).",
124
+ is_flag=True,
125
+ ),
126
+ severity: str | None = typer.Option(
127
+ None,
128
+ "--severity",
129
+ "-s",
130
+ help="Minimum severity filter: critical | high | medium | low.",
131
+ ),
132
+ output: str = typer.Option(
133
+ "terminal",
134
+ "--output",
135
+ "-o",
136
+ help="Output format: terminal (default) | json | markdown.",
137
+ ),
138
+ notify: str | None = typer.Option(
139
+ None,
140
+ "--notify",
141
+ "-n",
142
+ help="Send results to: slack (requires webhook in config).",
143
+ ),
144
+ ) -> None:
145
+ """
146
+ Scan a Terraform workspace for infrastructure drift.
147
+
148
+ Runs [bold]terraform plan -refresh-only -json[/bold], attributes each drift
149
+ to a CloudTrail event, scores severity, and suggests remediations.
150
+
151
+ [bold]Examples:[/bold]
152
+
153
+ [cyan]drifty scan[/cyan]
154
+
155
+ [cyan]drifty scan --workspace ./infra --attribute --output json[/cyan]
156
+
157
+ [cyan]drifty scan --severity high --notify slack[/cyan]
158
+ """
159
+ # Validate output format
160
+ valid_outputs = ("terminal", "json", "markdown")
161
+ if output not in valid_outputs:
162
+ console.print(
163
+ f"[red]✗ Invalid output format:[/red] [bold]{output}[/bold]. "
164
+ f"Choose from: {', '.join(valid_outputs)}"
165
+ )
166
+ raise typer.Exit(code=1)
167
+
168
+ # Validate severity filter
169
+ valid_severities = ("critical", "high", "medium", "low")
170
+ if severity and severity.lower() not in valid_severities:
171
+ console.print(
172
+ f"[red]✗ Invalid severity:[/red] [bold]{severity}[/bold]. "
173
+ f"Choose from: {', '.join(valid_severities)}"
174
+ )
175
+ raise typer.Exit(code=1)
176
+
177
+ # Validate notify target
178
+ valid_notify = ("slack",)
179
+ if notify and notify.lower() not in valid_notify:
180
+ console.print(
181
+ f"[red]✗ Invalid notify target:[/red] [bold]{notify}[/bold]. "
182
+ f"Choose from: {', '.join(valid_notify)}"
183
+ )
184
+ raise typer.Exit(code=1)
185
+
186
+ # -----------------------------------------------------------------------
187
+ # STUB: real logic wired in Step 4 (scanner), Step 5 (scorer),
188
+ # Step 6 (cloudtrail), Step 7 (reporter)
189
+ # -----------------------------------------------------------------------
190
+ from drifty.reporter import render
191
+ from drifty.scanner import run_scan
192
+
193
+ findings = run_scan(
194
+ workspace=workspace,
195
+ profile=profile,
196
+ with_attribution=attribute,
197
+ severity_filter=severity.lower() if severity else None,
198
+ )
199
+
200
+ render(findings, output_format=output, workspace=workspace)
201
+
202
+ if notify:
203
+ console.print(
204
+ f"\n[yellow]⚠ Notify via [bold]{notify}[/bold] not yet configured. "
205
+ "Run [bold]drifty config set slack_webhook=<URL>[/bold] first.[/yellow]"
206
+ )
207
+
208
+
209
+ # ---------------------------------------------------------------------------
210
+ # drifty report
211
+ # ---------------------------------------------------------------------------
212
+ @app.command("report")
213
+ def cmd_report(
214
+ workspace: Path = typer.Option(
215
+ Path("."),
216
+ "--workspace",
217
+ "-w",
218
+ help="Path to Terraform workspace directory.",
219
+ exists=True,
220
+ file_okay=False,
221
+ dir_okay=True,
222
+ resolve_path=True,
223
+ ),
224
+ format: str = typer.Option(
225
+ "markdown",
226
+ "--format",
227
+ "-f",
228
+ help="Report format: markdown | json.",
229
+ ),
230
+ output_file: Path | None = typer.Option(
231
+ None,
232
+ "--out",
233
+ help="File path to write report to. Defaults to ./drifty-report-YYYY-MM-DD.md",
234
+ ),
235
+ ) -> None:
236
+ """
237
+ Generate a standalone drift report for the current workspace.
238
+
239
+ [bold]Examples:[/bold]
240
+
241
+ [cyan]drifty report[/cyan]
242
+
243
+ [cyan]drifty report --format json --out ./reports/drift.json[/cyan]
244
+ """
245
+ valid_formats = ("markdown", "json")
246
+ if format not in valid_formats:
247
+ console.print(
248
+ f"[red]✗ Invalid format:[/red] [bold]{format}[/bold]. "
249
+ f"Choose from: {', '.join(valid_formats)}"
250
+ )
251
+ raise typer.Exit(code=1)
252
+
253
+ from drifty.reporter import generate_report
254
+ from drifty.scanner import run_scan
255
+
256
+ findings = run_scan(workspace=workspace, profile="default")
257
+ generate_report(findings, format=format, output_file=output_file, workspace=workspace)
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # drifty config show
262
+ # ---------------------------------------------------------------------------
263
+
264
+
265
+ @config_app.command("show")
266
+ def cmd_config_show() -> None:
267
+ """
268
+ Display the current drifty configuration.
269
+ """
270
+ show_config()
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # drifty config set
275
+ # ---------------------------------------------------------------------------
276
+
277
+
278
+ @config_app.command("set")
279
+ def cmd_config_set(
280
+ key_value: str = typer.Argument(
281
+ ...,
282
+ help="Config key=value pair. Example: slack_webhook=https://hooks.slack.com/...",
283
+ metavar="KEY=VALUE",
284
+ ),
285
+ ) -> None:
286
+ """
287
+ Set a configuration value.
288
+
289
+ [bold]Examples:[/bold]
290
+
291
+ [cyan]drifty config set slack_webhook=https://hooks.slack.com/T.../B.../xxx[/cyan]
292
+
293
+ [cyan]drifty config set default_severity=high[/cyan]
294
+
295
+ [cyan]drifty config set default_profile=prod[/cyan]
296
+ """
297
+ if "=" not in key_value:
298
+ console.print(
299
+ "[red]✗ Invalid format.[/red] Use [bold]KEY=VALUE[/bold], "
300
+ "e.g. [cyan]drifty config set slack_webhook=https://...[/cyan]"
301
+ )
302
+ raise typer.Exit(code=1)
303
+
304
+ key, _, value = key_value.partition("=")
305
+ set_config_value(key.strip(), value.strip())
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Entry point (for direct execution: python -m drifty)
310
+ # ---------------------------------------------------------------------------
311
+
312
+ if __name__ == "__main__":
313
+ app()
drifty/cloudtrail.py ADDED
@@ -0,0 +1,308 @@
1
+ """
2
+ cloudtrail.py — boto3 CloudTrail attribution for DriftFinding instances.
3
+
4
+ Given a DriftFinding, queries CloudTrail LookupEvents to find the most recent
5
+ API call that touched that resource. Returns a dict with:
6
+ {
7
+ "principal": "arn:aws:iam::123456789:user/john.doe",
8
+ "timestamp": "2026-06-03T14:22:11+00:00",
9
+ "action": "ModifySecurityGroupRules",
10
+ }
11
+
12
+ CloudTrail LookupEvents constraints:
13
+ - Max lookback: 90 days
14
+ - Lookup by: ResourceName (resource ID or ARN), EventName, Username
15
+ - Returns: up to 50 events per page, newest first
16
+ - Only covers management events (not S3 data events unless enabled)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from datetime import datetime, timedelta, timezone
22
+ from typing import TYPE_CHECKING
23
+
24
+ import boto3
25
+ import botocore.exceptions
26
+ from rich.console import Console
27
+
28
+ if TYPE_CHECKING:
29
+ from drifty.scanner import DriftFinding
30
+
31
+ console = Console()
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Resource type → CloudTrail event name patterns
36
+ # These help narrow the search to the most relevant API calls.
37
+ # ---------------------------------------------------------------------------
38
+
39
+ RESOURCE_EVENT_PREFIXES: dict[str, list[str]] = {
40
+ "aws_security_group": [
41
+ "AuthorizeSecurityGroup",
42
+ "RevokeSecurityGroup",
43
+ "ModifySecurityGroup",
44
+ "CreateSecurityGroup",
45
+ ],
46
+ "aws_security_group_rule": ["AuthorizeSecurityGroup", "RevokeSecurityGroup"],
47
+ "aws_iam_role": [
48
+ "UpdateAssumeRolePolicy",
49
+ "AttachRolePolicy",
50
+ "DetachRolePolicy",
51
+ "PutRolePolicy",
52
+ ],
53
+ "aws_iam_role_policy": [
54
+ "PutRolePolicy",
55
+ "DeleteRolePolicy",
56
+ "AttachRolePolicy",
57
+ "DetachRolePolicy",
58
+ ],
59
+ "aws_iam_policy": ["CreatePolicy", "CreatePolicyVersion", "DeletePolicyVersion"],
60
+ "aws_instance": [
61
+ "ModifyInstanceAttribute",
62
+ "StartInstances",
63
+ "StopInstances",
64
+ "RebootInstances",
65
+ "TerminateInstances",
66
+ ],
67
+ "aws_s3_bucket_policy": ["PutBucketPolicy", "DeleteBucketPolicy"],
68
+ "aws_s3_bucket_public_access_block": ["PutPublicAccessBlock", "DeletePublicAccessBlock"],
69
+ "aws_s3_bucket": [
70
+ "PutBucketTagging",
71
+ "DeleteBucketTagging",
72
+ "PutBucketVersioning",
73
+ "PutBucketAcl",
74
+ ],
75
+ "aws_rds_instance": ["ModifyDBInstance", "RebootDBInstance"],
76
+ "aws_rds_cluster": ["ModifyDBCluster"],
77
+ "aws_lambda_function": [
78
+ "UpdateFunctionCode",
79
+ "UpdateFunctionConfiguration",
80
+ "TagResource",
81
+ "UntagResource",
82
+ ],
83
+ "aws_lb": ["ModifyLoadBalancerAttributes", "SetSecurityGroups", "ModifyListener"],
84
+ "aws_alb": ["ModifyLoadBalancerAttributes", "SetSecurityGroups"],
85
+ "aws_autoscaling_group": ["UpdateAutoScalingGroup", "SetDesiredCapacity"],
86
+ "aws_cloudwatch_metric_alarm": ["PutMetricAlarm", "DeleteAlarms"],
87
+ "aws_eks_cluster": ["UpdateClusterConfig", "UpdateClusterVersion"],
88
+ }
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # Public API
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ def attribute_finding(
97
+ finding: DriftFinding,
98
+ profile: str = "default",
99
+ lookback_days: int = 90,
100
+ ) -> dict | None:
101
+ """
102
+ Query CloudTrail for the most recent API event touching the given resource.
103
+
104
+ Returns:
105
+ dict with keys: principal, timestamp, action
106
+ None if no event found or CloudTrail is inaccessible.
107
+ """
108
+ try:
109
+ session = boto3.Session(profile_name=profile)
110
+ client = session.client("cloudtrail")
111
+ except botocore.exceptions.ProfileNotFound:
112
+ console.print(
113
+ f"[yellow]⚠ AWS profile [bold]{profile!r}[/bold] not found. "
114
+ "Skipping CloudTrail attribution.[/yellow]"
115
+ )
116
+ return None
117
+ except Exception as e:
118
+ console.print(f"[yellow]⚠ Could not create AWS session: {e}[/yellow]")
119
+ return None
120
+
121
+ resource_id = finding.resource_id
122
+ resource_type = finding.resource_type
123
+
124
+ # Build time window
125
+ end_time = datetime.now(tz=timezone.utc)
126
+ start_time = end_time - timedelta(days=min(lookback_days, 90))
127
+
128
+ # Try lookup by resource ID first, then by ARN pattern if needed
129
+ lookup_values = _build_lookup_values(resource_type, resource_id)
130
+
131
+ for lookup_value in lookup_values:
132
+ event = _lookup_event(
133
+ client=client,
134
+ resource_name=lookup_value,
135
+ start_time=start_time,
136
+ end_time=end_time,
137
+ resource_type=resource_type,
138
+ )
139
+ if event:
140
+ return event
141
+
142
+ return None
143
+
144
+
145
+ def _lookup_event(
146
+ client,
147
+ resource_name: str,
148
+ start_time: datetime,
149
+ end_time: datetime,
150
+ resource_type: str,
151
+ ) -> dict | None:
152
+ """
153
+ Call CloudTrail LookupEvents with a ResourceName filter.
154
+ Pages through results and returns the most recent matching event.
155
+ """
156
+ lookup_attrs = [{"AttributeKey": "ResourceName", "AttributeValue": resource_name}]
157
+
158
+ try:
159
+ paginator = client.get_paginator("lookup_events")
160
+ pages = paginator.paginate(
161
+ LookupAttributes=lookup_attrs,
162
+ StartTime=start_time,
163
+ EndTime=end_time,
164
+ PaginationConfig={"MaxItems": 50, "PageSize": 50},
165
+ )
166
+
167
+ for page in pages:
168
+ events = page.get("Events", [])
169
+ if not events:
170
+ continue
171
+
172
+ # Events are returned newest-first — take the first match
173
+ # that isn't an automated AWS service action
174
+ for event in events:
175
+ principal = _extract_principal(event)
176
+ if _is_automated_service_event(principal):
177
+ continue
178
+
179
+ return {
180
+ "principal": principal,
181
+ "timestamp": event["EventTime"].isoformat(),
182
+ "action": event.get("EventName", "Unknown"),
183
+ }
184
+
185
+ # If all events were automated, return the newest one anyway
186
+ if events:
187
+ event = events[0]
188
+ return {
189
+ "principal": _extract_principal(event),
190
+ "timestamp": event["EventTime"].isoformat(),
191
+ "action": event.get("EventName", "Unknown"),
192
+ }
193
+
194
+ except botocore.exceptions.ClientError as e:
195
+ error_code = e.response["Error"]["Code"]
196
+ if error_code == "InvalidLookupAttributesException":
197
+ # Resource name format not supported by CloudTrail lookup
198
+ return None
199
+ console.print(
200
+ f"[yellow]⚠ CloudTrail lookup failed ({error_code}): "
201
+ f"{e.response['Error']['Message']}[/yellow]"
202
+ )
203
+ return None
204
+ except botocore.exceptions.EndpointResolutionError:
205
+ console.print(
206
+ "[yellow]⚠ Could not reach CloudTrail endpoint. " "Check AWS credentials.[/yellow]"
207
+ )
208
+ except Exception as e:
209
+ console.print(f"[yellow]⚠ Unexpected CloudTrail error: {e}[/yellow]")
210
+ return None
211
+
212
+ return None
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Internal helpers
217
+ # ---------------------------------------------------------------------------
218
+
219
+
220
+ def _build_lookup_values(resource_type: str, resource_id: str) -> list[str]:
221
+ """
222
+ Build a prioritized list of values to try for CloudTrail ResourceName lookup.
223
+ CloudTrail accepts: resource IDs, ARNs, resource names.
224
+ We try the raw resource_id first, then common ARN patterns.
225
+ """
226
+ values = [resource_id]
227
+
228
+ # Add ARN patterns for resource types where CloudTrail indexes by ARN
229
+ arn_patterns = {
230
+ "aws_s3_bucket": f"arn:aws:s3:::{resource_id}",
231
+ "aws_s3_bucket_policy": f"arn:aws:s3:::{resource_id}",
232
+ "aws_s3_bucket_public_access_block": f"arn:aws:s3:::{resource_id}",
233
+ "aws_lambda_function": f"arn:aws:lambda:*:*:function:{resource_id}",
234
+ }
235
+
236
+ arn = arn_patterns.get(resource_type)
237
+ if arn and arn not in values:
238
+ values.append(arn)
239
+
240
+ return values
241
+
242
+
243
+ def _extract_principal(event: dict) -> str:
244
+ """
245
+ Extract the IAM principal from a CloudTrail event.
246
+ Returns the most specific identifier available.
247
+ """
248
+ username = event.get("Username", "")
249
+
250
+ # CloudTrail Username field is the most direct — use it if it looks like an ARN
251
+ if username.startswith("arn:aws"):
252
+ return username
253
+
254
+ # Fall back to CloudTrailEvent JSON for deeper principal info
255
+ import json
256
+
257
+ raw = event.get("CloudTrailEvent", "{}")
258
+ try:
259
+ ct_event = json.loads(raw)
260
+ identity = ct_event.get("userIdentity", {})
261
+
262
+ # Prefer ARN → assumed-role session → username → type
263
+ arn = identity.get("arn")
264
+ if arn:
265
+ return arn
266
+
267
+ session_context = identity.get("sessionContext", {})
268
+ session_issuer = session_context.get("sessionIssuer", {})
269
+ if session_issuer.get("arn"):
270
+ return session_issuer["arn"]
271
+
272
+ if username:
273
+ return username
274
+
275
+ return identity.get("type", "unknown")
276
+
277
+ except (json.JSONDecodeError, AttributeError):
278
+ return username or "unknown"
279
+
280
+
281
+ def _is_automated_service_event(principal: str) -> bool:
282
+ """
283
+ Return True if the principal looks like an automated AWS service call
284
+ rather than a human or team automation action.
285
+ Helps surface the human-initiated change first.
286
+ """
287
+ automated_patterns = [
288
+ "elasticloadbalancing.amazonaws.com",
289
+ "autoscaling.amazonaws.com",
290
+ "ec2.amazonaws.com",
291
+ "lambda.amazonaws.com",
292
+ "rds.amazonaws.com",
293
+ "AWSServiceRoleFor",
294
+ ]
295
+ return any(pattern in principal for pattern in automated_patterns)
296
+
297
+
298
+ def format_attribution(finding: DriftFinding) -> str:
299
+ """
300
+ Format attribution fields from a DriftFinding into a human-readable string.
301
+ Used by reporter.py.
302
+ """
303
+ if finding.attributed_to:
304
+ return (
305
+ f"{finding.attributed_to} via {finding.attributed_action} "
306
+ f"at {finding.attributed_at}"
307
+ )
308
+ return "attribution unavailable (event outside 90-day CloudTrail window)"