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.
- driftsentry/__init__.py +8 -0
- driftsentry/attribution/__init__.py +1 -0
- driftsentry/attribution/cloudtrail.py +260 -0
- driftsentry/cli/__init__.py +1 -0
- driftsentry/cli/main.py +54 -0
- driftsentry/cli/remediate.py +195 -0
- driftsentry/cli/report.py +113 -0
- driftsentry/cli/scan.py +218 -0
- driftsentry/core/__init__.py +1 -0
- driftsentry/core/config.py +214 -0
- driftsentry/core/differ.py +286 -0
- driftsentry/core/models.py +257 -0
- driftsentry/core/scanner.py +321 -0
- driftsentry/notifications/__init__.py +1 -0
- driftsentry/notifications/slack.py +109 -0
- driftsentry/notifications/stdout.py +27 -0
- driftsentry/output/__init__.py +8 -0
- driftsentry/output/html.py +277 -0
- driftsentry/output/json_fmt.py +39 -0
- driftsentry/output/markdown.py +120 -0
- driftsentry/output/table.py +196 -0
- driftsentry/policy/__init__.py +1 -0
- driftsentry/policy/engine.py +160 -0
- driftsentry/policy/rules.py +112 -0
- driftsentry/providers/__init__.py +5 -0
- driftsentry/providers/aws/__init__.py +5 -0
- driftsentry/providers/aws/mapping.py +216 -0
- driftsentry/providers/aws/provider.py +134 -0
- driftsentry/providers/aws/resources/__init__.py +17 -0
- driftsentry/providers/aws/resources/ec2.py +301 -0
- driftsentry/providers/aws/resources/ecs.py +166 -0
- driftsentry/providers/aws/resources/iam.py +214 -0
- driftsentry/providers/aws/resources/lambda_fn.py +85 -0
- driftsentry/providers/aws/resources/rds.py +97 -0
- driftsentry/providers/aws/resources/s3.py +153 -0
- driftsentry/providers/base.py +110 -0
- driftsentry/remediation/__init__.py +1 -0
- driftsentry/remediation/generator.py +312 -0
- driftsentry/remediation/pr_creator.py +124 -0
- driftsentry/remediation/templates/import_block.tf.j2 +5 -0
- driftsentry/remediation/templates/pr_body.md.j2 +22 -0
- driftsentry/remediation/templates/resource_block.tf.j2 +6 -0
- driftsentry/state/__init__.py +13 -0
- driftsentry/state/base.py +51 -0
- driftsentry/state/factory.py +48 -0
- driftsentry/state/local.py +137 -0
- driftsentry/state/s3.py +109 -0
- driftsentry-0.1.0.dist-info/METADATA +502 -0
- driftsentry-0.1.0.dist-info/RECORD +52 -0
- driftsentry-0.1.0.dist-info/WHEEL +4 -0
- driftsentry-0.1.0.dist-info/entry_points.txt +2 -0
- driftsentry-0.1.0.dist-info/licenses/LICENSE +190 -0
driftsentry/__init__.py
ADDED
|
@@ -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."""
|
driftsentry/cli/main.py
ADDED
|
@@ -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()
|