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 +0 -0
- drifty/__init__.py +5 -0
- drifty/cli.py +313 -0
- drifty/cloudtrail.py +308 -0
- drifty/config.py +269 -0
- drifty/reporter.py +349 -0
- drifty/scanner.py +330 -0
- drifty/scorer.py +216 -0
- drifty-0.1.0.dist-info/METADATA +273 -0
- drifty-0.1.0.dist-info/RECORD +13 -0
- drifty-0.1.0.dist-info/WHEEL +4 -0
- drifty-0.1.0.dist-info/entry_points.txt +3 -0
- drifty-0.1.0.dist-info/licenses/LICENSE +21 -0
drifty/.DS_Store
ADDED
|
Binary file
|
drifty/__init__.py
ADDED
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)"
|