driftwatch-cli 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.
@@ -0,0 +1,114 @@
1
+ import os
2
+ import smtplib
3
+ import requests
4
+ from email.mime.text import MIMEText
5
+ from email.mime.multipart import MIMEMultipart
6
+
7
+ def _format_drift_results(drift_results: list, include_ai: bool = True) -> list:
8
+ lines = []
9
+ for result in drift_results:
10
+ lines.append(
11
+ f"[{result.drift_type.value}] {result.resource_type}: "
12
+ f"{result.resource_name} ({result.resource_id})"
13
+ )
14
+ if result.diff:
15
+ for attr, values in result.diff.items():
16
+ lines.append(
17
+ f" - {attr}: Expected '{values['terraform']}', "
18
+ f"Found '{values['live']}'"
19
+ )
20
+ if include_ai and getattr(result, "ai_analysis", ""):
21
+ lines.append(f" - AI Analysis: {result.ai_analysis}")
22
+ lines.append("")
23
+ return lines
24
+
25
+ def send_telegram_alert(message: str):
26
+ bot_token = os.environ.get("TELEGRAM_BOT_TOKEN")
27
+ chat_id = os.environ.get("TELEGRAM_CHAT_ID")
28
+
29
+ if not bot_token or not chat_id:
30
+ print("Telegram credentials missing in environment variables.")
31
+ return
32
+
33
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
34
+ payload = {
35
+ "chat_id": chat_id,
36
+ "text": message
37
+ }
38
+
39
+ try:
40
+ response = requests.post(url, json=payload, timeout=15)
41
+ response.raise_for_status()
42
+ print("Telegram alert sent successfully.")
43
+ except Exception as e:
44
+ print(f"Failed to send Telegram alert: {e}")
45
+
46
+ def send_slack_alert(webhook_url: str, drift_results: list):
47
+ if not webhook_url:
48
+ return
49
+
50
+ message_lines = ["*DriftWatch Alert: Infrastructure Drift Detected!*"]
51
+ message_lines.extend(_format_drift_results(drift_results))
52
+
53
+ payload = {"text": "\n".join(message_lines)}
54
+
55
+ try:
56
+ response = requests.post(webhook_url, json=payload, timeout=15)
57
+ response.raise_for_status()
58
+ print("Slack alert sent successfully.")
59
+ except Exception as e:
60
+ print(f"Failed to send Slack alert: {e}")
61
+
62
+ def send_email_alert(smtp_server: str, smtp_port: int, sender_email: str, sender_password: str, recipient_email: str, drift_results: list):
63
+ if not all([smtp_server, sender_email, sender_password, recipient_email]):
64
+ return
65
+
66
+ msg = MIMEMultipart()
67
+ msg['From'] = sender_email
68
+ msg['To'] = recipient_email
69
+ msg['Subject'] = "DriftWatch Alert: Infrastructure Drift Detected"
70
+
71
+ body_lines = ["DriftWatch has detected changes in your infrastructure:\n"]
72
+ body_lines.extend(_format_drift_results(drift_results))
73
+
74
+ msg.attach(MIMEText("\n".join(body_lines), 'plain'))
75
+
76
+ try:
77
+ server = smtplib.SMTP(smtp_server, smtp_port, timeout=15)
78
+ server.starttls()
79
+ server.login(sender_email, sender_password)
80
+ server.send_message(msg)
81
+ server.quit()
82
+ print("Email alert sent successfully.")
83
+ except Exception as e:
84
+ print(f"Failed to send Email alert: {e}")
85
+
86
+ def process_alerts(drift_results: list):
87
+ if not drift_results:
88
+ return
89
+
90
+ slack_webhook = os.environ.get("SLACK_WEBHOOK_URL")
91
+ smtp_server = os.environ.get("SMTP_SERVER", "smtp.gmail.com")
92
+ smtp_port = int(os.environ.get("SMTP_PORT", 587))
93
+ sender_email = os.environ.get("SENDER_EMAIL")
94
+ sender_password = os.environ.get("SENDER_PASSWORD")
95
+ recipient_email = os.environ.get("RECIPIENT_EMAIL")
96
+ bot_token = os.environ.get("TELEGRAM_BOT_TOKEN")
97
+ chat_id = os.environ.get("TELEGRAM_CHAT_ID")
98
+
99
+ if slack_webhook:
100
+ send_slack_alert(slack_webhook, drift_results)
101
+
102
+ if sender_email and sender_password and recipient_email:
103
+ send_email_alert(smtp_server, smtp_port, sender_email, sender_password, recipient_email, drift_results)
104
+
105
+ if bot_token and chat_id:
106
+ message_lines = ["DriftWatch Alert Summary:"]
107
+ message_lines.extend(_format_drift_results(drift_results, include_ai=False))
108
+
109
+ telegram_message = "\n".join(message_lines)
110
+
111
+ if len(telegram_message) > 4000:
112
+ telegram_message = telegram_message[:4000] + "\n...[TRUNCATED]"
113
+
114
+ send_telegram_alert(telegram_message)
@@ -0,0 +1,273 @@
1
+ import os
2
+ import boto3
3
+
4
+ def get_environment_tag(tags):
5
+ if not tags:
6
+ return 'unknown'
7
+ if isinstance(tags, dict):
8
+ value = tags.get('Environment')
9
+ return value.lower() if value else 'unknown'
10
+ for tag in tags:
11
+ if isinstance(tag, dict) and tag.get('Key') == 'Environment':
12
+ return tag.get('Value').lower()
13
+ return 'unknown'
14
+
15
+ def confirm_action(action_desc: str, env: str = 'unknown', is_disruptive: bool = False) -> bool:
16
+ if env in ['dev', 'staging']:
17
+ print(f"[*] [{env.upper()}] Auto-approving: {action_desc}")
18
+ return True
19
+
20
+ print(f"[!] [{env.upper()}] Protection Active. Manual action required.")
21
+ if is_disruptive:
22
+ print("[!] WARNING: This is a disruptive action -> Downtime risk!")
23
+
24
+ while True:
25
+ choice = input(f"⚠️ {action_desc}. Proceed? (y/n): ").strip().lower()
26
+ if choice in ['y', 'yes']:
27
+ return True
28
+ elif choice in ['n', 'no']:
29
+ return False
30
+ print("Invalid input. Please enter 'y' or 'n'.")
31
+
32
+ def remediate_ec2_instance_type(region: str, instance_id: str, expected_type: str, env: str):
33
+ ec2 = boto3.client('ec2', region_name=region)
34
+ try:
35
+ desc = ec2.describe_instances(InstanceIds=[instance_id])
36
+ reservations = desc.get('Reservations', [])
37
+ if not reservations or not reservations[0].get('Instances'):
38
+ print(f"❌ EC2 instance {instance_id} not found.")
39
+ return
40
+ instance_info = reservations[0]['Instances'][0]
41
+
42
+ if instance_info.get('RootDeviceType') != 'ebs':
43
+ print(f"❌ Cannot modify instance {instance_id}: Root device is not EBS-backed (found: {instance_info.get('RootDeviceType')}).")
44
+ return
45
+
46
+ if instance_info.get('InstanceLifecycle') == 'spot':
47
+ print(f"❌ Cannot modify instance {instance_id}: Spot instances do not support instance type modification.")
48
+ return
49
+
50
+ state = instance_info.get('State', {}).get('Name')
51
+ if state not in ['running', 'stopped']:
52
+ print(f"❌ Cannot modify instance {instance_id}: Instance state is '{state}'. Must be 'running' or 'stopped'.")
53
+ return
54
+ except Exception as e:
55
+ print(f"❌ Pre-flight check failed for EC2 {instance_id}: {e}")
56
+ return
57
+
58
+ if not confirm_action(f"Change EC2 {instance_id} instance type to {expected_type}", env, True):
59
+ print(f"⏭️ Skipped remediation for EC2 {instance_id}")
60
+ return
61
+
62
+ was_running = (state == 'running')
63
+ stopped = False
64
+ try:
65
+ if was_running:
66
+ print(f"Stopping instance {instance_id} for remediation...")
67
+ ec2.stop_instances(InstanceIds=[instance_id])
68
+ waiter = ec2.get_waiter('instance_stopped')
69
+ waiter.wait(InstanceIds=[instance_id])
70
+ stopped = True
71
+
72
+ print(f"Modifying instance type to {expected_type}...")
73
+ ec2.modify_instance_attribute(
74
+ InstanceId=instance_id,
75
+ InstanceType={'Value': expected_type}
76
+ )
77
+
78
+ if was_running:
79
+ print(f"Restarting instance {instance_id}...")
80
+ ec2.start_instances(InstanceIds=[instance_id])
81
+ stopped = False
82
+
83
+ print(f"✅ [REMEDIATED] Successfully remediated {instance_id} back to {expected_type}")
84
+ except Exception as e:
85
+ print(f"❌ Failed to remediate EC2 {instance_id}: {e}")
86
+ if stopped:
87
+ try:
88
+ print(f"Attempting to restart EC2 {instance_id} after failed remediation...")
89
+ ec2.start_instances(InstanceIds=[instance_id])
90
+ except Exception as restart_error:
91
+ print(f"❌ Failed to restart EC2 {instance_id}: {restart_error}")
92
+
93
+ def remediate_security_group(region: str, sg_id: str, diff_data: dict, env: str):
94
+ ec2 = boto3.client('ec2', region_name=region)
95
+
96
+ if "ingress" in diff_data:
97
+ expected_ingress = diff_data["ingress"].get("terraform", [])
98
+ live_ingress = diff_data["ingress"].get("live", [])
99
+
100
+ print(f"Checking Security Group {sg_id} for Ingress drift...")
101
+ for live_rule in live_ingress:
102
+ if live_rule not in expected_ingress:
103
+ from_p = live_rule.get('from_port')
104
+ to_p = live_rule.get('to_port')
105
+ is_ssh = (from_p == 22 or to_p == 22 or (isinstance(from_p, int) and isinstance(to_p, int) and from_p <= 22 <= to_p))
106
+ if is_ssh:
107
+ print("⚠️ CAUTION: Rule being revoked includes SSH (Port 22). Active connections may be terminated.")
108
+ if confirm_action(f"Revoke unauthorized Inbound rule (Port {from_p}) in {sg_id}", env, is_ssh):
109
+ try:
110
+ ec2.revoke_security_group_ingress(GroupId=sg_id, IpPermissions=[{
111
+ 'IpProtocol': live_rule['protocol'], 'FromPort': live_rule['from_port'], 'ToPort': live_rule['to_port'],
112
+ 'IpRanges': [{'CidrIp': c} for c in live_rule.get('cidr_blocks', []) if c]
113
+ }])
114
+ print(f"✅ [REMEDIATED] Removed unauthorized Inbound Rule from {sg_id}")
115
+ except Exception as e:
116
+ print(f"❌ Failed to revoke Inbound rule: {e}")
117
+
118
+ for exp_rule in expected_ingress:
119
+ if exp_rule not in live_ingress:
120
+ if confirm_action(f"Restore missing IaC Inbound rule (Port {exp_rule.get('from_port')}) in {sg_id}", env, False):
121
+ try:
122
+ ec2.authorize_security_group_ingress(GroupId=sg_id, IpPermissions=[{
123
+ 'IpProtocol': exp_rule['protocol'], 'FromPort': exp_rule['from_port'], 'ToPort': exp_rule['to_port'],
124
+ 'IpRanges': [{'CidrIp': c} for c in exp_rule.get('cidr_blocks', []) if c]
125
+ }])
126
+ print(f"✅ [REMEDIATED] Restored missing Inbound Rule to {sg_id}")
127
+ except Exception as e:
128
+ print(f"❌ Failed to restore Inbound rule: {e}")
129
+
130
+ if "egress" in diff_data:
131
+ expected_egress = diff_data["egress"].get("terraform", [])
132
+ live_egress = diff_data["egress"].get("live", [])
133
+
134
+ print(f"Checking Security Group {sg_id} for Egress drift...")
135
+ for live_rule in live_egress:
136
+ if live_rule not in expected_egress:
137
+ if confirm_action(f"Revoke unauthorized Outbound rule (Port {live_rule.get('from_port')}) in {sg_id}", env, False):
138
+ try:
139
+ ec2.revoke_security_group_egress(GroupId=sg_id, IpPermissions=[{
140
+ 'IpProtocol': live_rule['protocol'], 'FromPort': live_rule['from_port'], 'ToPort': live_rule['to_port'],
141
+ 'IpRanges': [{'CidrIp': c} for c in live_rule.get('cidr_blocks', []) if c]
142
+ }])
143
+ print(f"✅ [REMEDIATED] Removed unauthorized Outbound Rule from {sg_id}")
144
+ except Exception as e:
145
+ print(f"❌ Failed to revoke Outbound rule: {e}")
146
+
147
+ for exp_rule in expected_egress:
148
+ if exp_rule not in live_egress:
149
+ if confirm_action(f"Restore missing IaC Outbound rule (Port {exp_rule.get('from_port')}) in {sg_id}", env, False):
150
+ try:
151
+ ec2.authorize_security_group_egress(GroupId=sg_id, IpPermissions=[{
152
+ 'IpProtocol': exp_rule['protocol'], 'FromPort': exp_rule['from_port'], 'ToPort': exp_rule['to_port'],
153
+ 'IpRanges': [{'CidrIp': c} for c in exp_rule.get('cidr_blocks', []) if c]
154
+ }])
155
+ print(f"✅ [REMEDIATED] Restored missing Outbound Rule to {sg_id}")
156
+ except Exception as e:
157
+ print(f"❌ Failed to restore Outbound rule: {e}")
158
+
159
+ if "description" in diff_data:
160
+ print(f"⚠️ Description drift detected for {sg_id}. SG descriptions cannot be updated dynamically in AWS EC2 API. Please update via Terraform.")
161
+
162
+ def remediate_s3_bucket(bucket_name: str, diff_data: dict, env: str):
163
+ s3 = boto3.client('s3')
164
+
165
+ if "tags" in diff_data:
166
+ expected_tags = diff_data["tags"].get("terraform", {})
167
+ if confirm_action(f"Restore IaC tags for S3 Bucket '{bucket_name}'", env, False):
168
+ try:
169
+ tag_set = [{'Key': k, 'Value': v} for k, v in expected_tags.items()]
170
+ s3.put_bucket_tagging(Bucket=bucket_name, Tagging={'TagSet': tag_set})
171
+ print(f"✅ [REMEDIATED] Successfully restored tags for {bucket_name}")
172
+ except Exception as e:
173
+ print(f"❌ Failed to restore S3 tags: {e}")
174
+
175
+ if "bucket" in diff_data:
176
+ print(f"⚠️ Bucket name drift detected for {bucket_name}. S3 buckets cannot be renamed. Please recreate via Terraform.")
177
+
178
+ def remediate_rds_instance(region: str, db_id: str, diff_data: dict, env: str, apply_immediately: bool = False):
179
+ rds = boto3.client('rds', region_name=region)
180
+ updates = {}
181
+
182
+ if "instance_class" in diff_data:
183
+ updates['DBInstanceClass'] = diff_data["instance_class"]["terraform"]
184
+ if "allocated_storage" in diff_data:
185
+ updates['AllocatedStorage'] = diff_data["allocated_storage"]["terraform"]
186
+
187
+ if updates:
188
+ mode_desc = "immediately (may force reboot)" if apply_immediately else "during next maintenance window"
189
+ if confirm_action(f"Modify RDS {db_id} ({mode_desc}) with configs: {updates}", env, apply_immediately):
190
+ try:
191
+ updates['ApplyImmediately'] = apply_immediately
192
+ rds.modify_db_instance(DBInstanceIdentifier=db_id, **updates)
193
+ print(f"✅ [REMEDIATED] Initiated RDS modification for {db_id} (ApplyImmediately={apply_immediately}).")
194
+ except Exception as e:
195
+ print(f"❌ Failed to remediate RDS {db_id}: {e}")
196
+
197
+ def remediate_lambda_function(region: str, func_name: str, diff_data: dict, env: str):
198
+ lam = boto3.client('lambda', region_name=region)
199
+ updates = {}
200
+
201
+ if "runtime" in diff_data:
202
+ updates['Runtime'] = diff_data["runtime"]["terraform"]
203
+ if "handler" in diff_data:
204
+ updates['Handler'] = diff_data["handler"]["terraform"]
205
+ if "memory_size" in diff_data:
206
+ updates['MemorySize'] = diff_data["memory_size"]["terraform"]
207
+ if "timeout" in diff_data:
208
+ updates['Timeout'] = diff_data["timeout"]["terraform"]
209
+
210
+ if updates:
211
+ if confirm_action(f"Modify Lambda {func_name} with {updates}", env, False):
212
+ try:
213
+ lam.update_function_configuration(FunctionName=func_name, **updates)
214
+ print(f"✅ [REMEDIATED] Successfully modified Lambda {func_name}")
215
+ except Exception as e:
216
+ print(f"❌ Failed to remediate Lambda {func_name}: {e}")
217
+
218
+ def remediate_iam_role(role_name: str, diff_data: dict, env: str):
219
+ iam = boto3.client('iam')
220
+ expected_policies = diff_data.get("attached_policies", {}).get("terraform", [])
221
+ live_policies = diff_data.get("attached_policies", {}).get("live", [])
222
+
223
+ print(f"Checking IAM Role {role_name} for unauthorized policies...")
224
+ for live_policy in live_policies:
225
+ if live_policy not in expected_policies:
226
+ if confirm_action(f"Detach unauthorized policy '{live_policy}' from Role '{role_name}'", env, False):
227
+ try:
228
+ iam.detach_role_policy(RoleName=role_name, PolicyArn=live_policy)
229
+ print(f"✅ [REMEDIATED] Successfully detached {live_policy} from {role_name}")
230
+ except Exception as e:
231
+ print(f"❌ Failed to detach policy from {role_name}: {e}")
232
+
233
+ for exp_policy in expected_policies:
234
+ if exp_policy not in live_policies:
235
+ if confirm_action(f"Attach missing IaC policy '{exp_policy}' to Role '{role_name}'", env, False):
236
+ try:
237
+ iam.attach_role_policy(RoleName=role_name, PolicyArn=exp_policy)
238
+ print(f"✅ [REMEDIATED] Successfully attached {exp_policy} to {role_name}")
239
+ except Exception as e:
240
+ print(f"❌ Failed to attach policy to {role_name}: {e}")
241
+
242
+ def process_remediation(drift_results: list):
243
+ region = os.environ.get("AWS_DEFAULT_REGION", "ap-south-1")
244
+ for result in drift_results:
245
+ attrs = result.live_attributes or result.tf_attributes or {}
246
+ tags = attrs.get('tags', {})
247
+ env = get_environment_tag(tags)
248
+
249
+ if result.drift_type.value == "MODIFIED":
250
+ print(f"\n--- Drift Detected: {result.resource_type} ({result.resource_id}) ---")
251
+
252
+ if result.resource_type == "aws_instance" and "instance_type" in result.diff:
253
+ expected_type = result.diff["instance_type"]["terraform"]
254
+ remediate_ec2_instance_type(region, result.resource_id, expected_type, env)
255
+
256
+ elif result.resource_type == "aws_security_group":
257
+ remediate_security_group(region, result.resource_id, result.diff, env)
258
+
259
+ elif result.resource_type == "aws_s3_bucket":
260
+ remediate_s3_bucket(result.resource_id, result.diff, env)
261
+
262
+ elif result.resource_type == "aws_db_instance":
263
+ remediate_rds_instance(region, result.resource_id, result.diff, env, apply_immediately=False)
264
+
265
+ elif result.resource_type == "aws_lambda_function":
266
+ remediate_lambda_function(region, result.resource_id, result.diff, env)
267
+
268
+ elif result.resource_type == "aws_iam_role":
269
+ remediate_iam_role(result.resource_id, result.diff, env)
270
+
271
+ elif result.drift_type.value in ["MISSING", "UNMANAGED"]:
272
+ print(f"\n--- {result.drift_type.value} Resource Detected: {result.resource_type} ({result.resource_id}) ---")
273
+ print(f"⚠️ Auto-remediation for {result.drift_type.value} resources is not executed directly. Please review the suggested Terraform template/import commands to reconcile.")
@@ -0,0 +1,70 @@
1
+ import json
2
+
3
+ def load_terraform_state(state_path: str) -> dict:
4
+ try:
5
+ with open(state_path) as f:
6
+ state = json.load(f)
7
+ except FileNotFoundError:
8
+ print(f"Error: Terraform state file not found at '{state_path}'")
9
+ return {}
10
+ except json.JSONDecodeError:
11
+ print(f"Error: Terraform state file at '{state_path}' is corrupted or invalid JSON.")
12
+ return {}
13
+
14
+ resources = {}
15
+
16
+ for resource in state.get("resources", []):
17
+ r_type = resource["type"]
18
+
19
+ if r_type == "archive_file":
20
+ continue
21
+
22
+ for instance in resource.get("instances", []):
23
+ attrs = instance.get("attributes", {})
24
+
25
+ if r_type == "aws_iam_role_policy_attachment":
26
+ role_name = attrs.get("role")
27
+ policy_arn = attrs.get("policy_arn")
28
+ if role_name and policy_arn:
29
+ if role_name not in resources:
30
+ resources[role_name] = {
31
+ "type": "aws_iam_role",
32
+ "name": role_name,
33
+ "attributes": {"id": role_name, "name": role_name, "attached_policies": []}
34
+ }
35
+ if "attached_policies" not in resources[role_name]["attributes"]:
36
+ resources[role_name]["attributes"]["attached_policies"] = []
37
+ resources[role_name]["attributes"]["attached_policies"].append(policy_arn)
38
+ resources[role_name]["attributes"]["attached_policies"].sort()
39
+ continue
40
+
41
+ resource_id = attrs.get("id")
42
+
43
+ tags = attrs.get("tags", {})
44
+ if tags and tags.get("Name"):
45
+ name = tags.get("Name")
46
+ elif r_type == "aws_lambda_function":
47
+ name = attrs.get("function_name", "Unknown")
48
+ elif r_type == "aws_db_instance":
49
+ name = attrs.get("identifier", "Unknown")
50
+ elif r_type == "aws_iam_role":
51
+ name = attrs.get("name", "Unknown")
52
+ if "attached_policies" not in attrs:
53
+ attrs["attached_policies"] = []
54
+ else:
55
+ name = "Unknown"
56
+
57
+ if resource_id:
58
+ if resource_id in resources and r_type == "aws_iam_role":
59
+ existing_policies = resources[resource_id]["attributes"].get("attached_policies", [])
60
+ attrs["attached_policies"] = sorted(set(
61
+ attrs.get("attached_policies", []) + existing_policies
62
+ ))
63
+
64
+ resources[resource_id] = {
65
+ "type": r_type,
66
+ "name": name,
67
+ "attributes": attrs
68
+ }
69
+
70
+ return resources
driftwatch/__init__.py ADDED
File without changes
driftwatch/cli.py ADDED
@@ -0,0 +1,194 @@
1
+ import os
2
+ import typer
3
+ import boto3
4
+ from datetime import datetime
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ from drift_engine.core import detect_drift, get_severity
10
+ from drift_engine.aws_client import get_resource_cost
11
+ from drift_engine.explain import get_drift_explanation, get_deterministic_remediation_suggestion
12
+ from drift_engine.models import DriftType
13
+ from drift_engine.notifications import process_alerts
14
+ from drift_engine.database import save_drift_to_db
15
+ from drift_engine.remediation import process_remediation
16
+
17
+ app = typer.Typer(help="DriftWatch CLI - Detect, Explain, and Remediate Terraform Infrastructure Drift.")
18
+
19
+ SEVERITY_RANK = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
20
+
21
+ def _resolve_region(region: str = None) -> str:
22
+ actual_region = region or os.environ.get("AWS_DEFAULT_REGION")
23
+ if not actual_region:
24
+ try:
25
+ actual_region = boto3.Session().region_name
26
+ except Exception:
27
+ actual_region = None
28
+ return actual_region
29
+
30
+ def _run_scan(state_path: str, region: str) -> tuple:
31
+ os.environ["TF_STATE_PATH"] = state_path
32
+ os.environ["AWS_DEFAULT_REGION"] = region
33
+ return detect_drift(state_path, region)
34
+
35
+ def _render_report(results: list, total_scanned: int) -> tuple[str, int, int]:
36
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
37
+ typer.echo("=== DRIFTWATCH SCAN REPORT ===")
38
+ typer.echo(f"Scan time: {current_time} | Resources scanned: {total_scanned}\n")
39
+
40
+ if not results:
41
+ typer.secho("No drift detected. Infrastructure matches IaC.", fg=typer.colors.GREEN)
42
+ return "LOW", 0, 0
43
+
44
+ highest_severity_found = "LOW"
45
+ crit_count = 0
46
+ high_count = 0
47
+
48
+ for r in results:
49
+ severity = get_severity(r.resource_type, r.drift_type, r.diff)
50
+
51
+ if SEVERITY_RANK.get(severity, 0) > SEVERITY_RANK.get(highest_severity_found, 0):
52
+ highest_severity_found = severity
53
+
54
+ if severity == "CRITICAL":
55
+ crit_count += 1
56
+ color = typer.colors.RED
57
+ elif severity == "HIGH":
58
+ high_count += 1
59
+ color = typer.colors.YELLOW
60
+ else:
61
+ color = typer.colors.BLUE
62
+
63
+ typer.secho(f"[{r.drift_type.value}] {r.resource_type}: {r.resource_id}", fg=color, bold=True)
64
+ ai_text = ""
65
+
66
+ if r.drift_type == DriftType.UNMANAGED and r.resource_type == "aws_instance":
67
+ cost = "{:,.2f}".format(get_resource_cost(r.resource_id))
68
+ inst_type = r.live_attributes.get("instance_type", "unknown")
69
+ typer.echo(f" Type: {inst_type} (created manually in console)")
70
+ typer.echo(f" Severity: {severity} | Cost: +${cost}/month (untracked)")
71
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, r.live_attributes, r.drift_type.value)
72
+ elif r.diff:
73
+ typer.echo(f" Severity: {severity}")
74
+ for attr, vals in r.diff.items():
75
+ typer.echo(f" Attribute: {attr}")
76
+ typer.echo(f" Terraform: {vals.get('terraform')}")
77
+ typer.echo(f" Live AWS: {vals.get('live')}")
78
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, r.diff, r.drift_type.value)
79
+ else:
80
+ typer.echo(f" Severity: {severity}")
81
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, {"status": "missing"}, r.drift_type.value)
82
+
83
+ if ai_text:
84
+ typer.echo(f" AI Analysis: {ai_text}\n")
85
+ r.ai_analysis = ai_text
86
+ else:
87
+ typer.echo("\n")
88
+
89
+ typer.echo(f"Total drift found: {len(results)} resources | CRITICAL: {crit_count} HIGH: {high_count}\n")
90
+ return highest_severity_found, crit_count, high_count
91
+
92
+ def _dispatch_alerts(results: list):
93
+ process_alerts(results)
94
+ save_drift_to_db(results)
95
+
96
+ def _check_gate(highest_severity_found: str, fail_on: str = None):
97
+ if fail_on and SEVERITY_RANK.get(highest_severity_found, 0) >= SEVERITY_RANK.get(fail_on.upper(), 0):
98
+ typer.secho(
99
+ f"\nBUILD FAILED: highest severity found is {highest_severity_found} (gate: {fail_on.upper()})",
100
+ fg=typer.colors.RED, bold=True,
101
+ )
102
+ raise typer.Exit(code=1)
103
+
104
+ @app.command()
105
+ def scan(
106
+ state: str = typer.Option("terraform/terraform.tfstate", help="Path to Terraform state file."),
107
+ region: str = typer.Option(None, help="Target AWS region."),
108
+ fail_on: str = typer.Option(None, help="Severity threshold to trigger non-zero exit code.")
109
+ ):
110
+ """Scan infrastructure against Terraform state and identify drift."""
111
+ actual_region = _resolve_region(region)
112
+ if not actual_region:
113
+ typer.secho("No AWS region found. Pass --region or set AWS_DEFAULT_REGION.", fg=typer.colors.RED)
114
+ raise typer.Exit(1)
115
+
116
+ typer.echo(f"Scanning AWS Infrastructure in {actual_region}...\n")
117
+ try:
118
+ results, total_scanned = _run_scan(state, actual_region)
119
+ highest_severity, _, _ = _render_report(results, total_scanned)
120
+
121
+ if results:
122
+ _dispatch_alerts(results)
123
+ typer.echo("Tip: run 'driftwatch remediate <resource_id>' to fix a specific resource.\n")
124
+
125
+ _check_gate(highest_severity, fail_on)
126
+ except typer.Exit:
127
+ raise
128
+ except Exception as e:
129
+ typer.secho(f"Error during execution: {e}", fg=typer.colors.RED)
130
+ raise typer.Exit(code=1)
131
+
132
+ @app.command()
133
+ def explain(
134
+ resource_id: str,
135
+ state: str = typer.Option("terraform/terraform.tfstate", help="Path to Terraform state file."),
136
+ region: str = typer.Option(None, help="Target AWS region.")
137
+ ):
138
+ """Explain the security/operational impact of detected drift using AI and suggest IaC fixes."""
139
+ actual_region = _resolve_region(region)
140
+ if not actual_region:
141
+ typer.secho("No AWS region found. Pass --region or set AWS_DEFAULT_REGION.", fg=typer.colors.RED)
142
+ raise typer.Exit(1)
143
+
144
+ typer.echo(f"Fetching current drift state for {resource_id} in {actual_region}...")
145
+ results, _ = _run_scan(state, actual_region)
146
+ match = [r for r in results if r.resource_id == resource_id]
147
+
148
+ if not match:
149
+ typer.secho(f"No current drift found for {resource_id}.", fg=typer.colors.YELLOW)
150
+ raise typer.Exit(code=1)
151
+
152
+ r = match[0]
153
+ diff_data = r.diff if r.diff else r.live_attributes
154
+
155
+ typer.echo("Generating AI Risk Analysis...")
156
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, diff_data, r.drift_type.value)
157
+ typer.echo(f"\nAI Risk Analysis:\n{ai_text}\n")
158
+
159
+ deterministic_remediation = get_deterministic_remediation_suggestion(r.resource_type, r.resource_id, diff_data, r.drift_type.value)
160
+ typer.echo(f"Recommended IaC Remediation Command:\n{deterministic_remediation}\n")
161
+
162
+ @app.command()
163
+ def remediate(
164
+ resource_id: str,
165
+ state: str = typer.Option("terraform/terraform.tfstate", help="Path to Terraform state file."),
166
+ region: str = typer.Option(None, help="Target AWS region."),
167
+ dry_run: bool = typer.Option(True, "--dry-run/--apply", help="Dry run mode (default) or apply changes."),
168
+ ):
169
+ """Safely reconcile live infrastructure back to Terraform state."""
170
+ actual_region = _resolve_region(region)
171
+ if not actual_region:
172
+ typer.secho("No AWS region found. Pass --region or set AWS_DEFAULT_REGION.", fg=typer.colors.RED)
173
+ raise typer.Exit(1)
174
+
175
+ results, _ = _run_scan(state, actual_region)
176
+ match = [r for r in results if r.resource_id == resource_id]
177
+
178
+ if not match:
179
+ typer.secho(f"No current drift found for {resource_id}. Run 'driftwatch scan' first.", fg=typer.colors.YELLOW)
180
+ raise typer.Exit(code=1)
181
+
182
+ r = match[0]
183
+ typer.secho(f"[{r.drift_type.value}] {r.resource_type}: {r.resource_id}", bold=True)
184
+ for attr, vals in (r.diff or {}).items():
185
+ typer.echo(f" {attr}: terraform={vals.get('terraform')} live={vals.get('live')}")
186
+
187
+ if dry_run:
188
+ typer.secho("\n[DRY RUN] No changes made. Re-run with --apply to remediate.", fg=typer.colors.BLUE)
189
+ return
190
+
191
+ process_remediation(match)
192
+
193
+ if __name__ == "__main__":
194
+ app()