driftwatch-cli 3.0.0__tar.gz

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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nitin Gupta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: driftwatch-cli
3
+ Version: 3.0.0
4
+ Summary: CLI tool that detects Terraform infrastructure drift against live AWS, explains it with AI, and guides remediation.
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Requires-Dist: boto3
8
+ Requires-Dist: typer
9
+ Requires-Dist: groq
10
+ Requires-Dist: python-telegram-bot
11
+ Requires-Dist: psycopg2-binary
12
+ Requires-Dist: requests
13
+ Dynamic: license-file
File without changes
@@ -0,0 +1,217 @@
1
+ import boto3
2
+ from datetime import datetime, timedelta
3
+
4
+ def fetch_live_ec2_instances(region: str) -> dict:
5
+ ec2 = boto3.client("ec2", region_name=region)
6
+ live = {}
7
+ paginator = ec2.get_paginator("describe_instances")
8
+
9
+ for page in paginator.paginate():
10
+ for reservation in page["Reservations"]:
11
+ for instance in reservation["Instances"]:
12
+ if instance["State"]["Name"] == "terminated":
13
+ continue
14
+
15
+ tags_list = instance.get("Tags", [])
16
+ tags_dict = {t["Key"]: t["Value"] for t in tags_list}
17
+ name = tags_dict.get("Name", "Unknown")
18
+
19
+ sg_ids = []
20
+ for sg in instance.get("SecurityGroups", []):
21
+ sg_ids.append(sg.get("GroupId"))
22
+ sg_ids.sort()
23
+
24
+ live[instance["InstanceId"]] = {
25
+ "type": "aws_instance",
26
+ "name": name,
27
+ "attributes": {
28
+ "id": instance["InstanceId"],
29
+ "instance_type": instance["InstanceType"],
30
+ "ami": instance["ImageId"],
31
+ "tags": tags_dict,
32
+ "vpc_security_group_ids": sg_ids
33
+ },
34
+ }
35
+ return live
36
+
37
+ def fetch_live_s3_buckets(region: str) -> dict:
38
+ s3 = boto3.client("s3", region_name=region)
39
+ live = {}
40
+ try:
41
+ response = s3.list_buckets()
42
+ for bucket in response.get("Buckets", []):
43
+ bucket_name = bucket["Name"]
44
+ try:
45
+ tags_response = s3.get_bucket_tagging(Bucket=bucket_name)
46
+ tags_list = tags_response.get("TagSet", [])
47
+ tags_dict = {t["Key"]: t["Value"] for t in tags_list}
48
+ name = tags_dict.get("Name", bucket_name)
49
+ except Exception:
50
+ tags_dict = {}
51
+ name = bucket_name
52
+
53
+ live[bucket_name] = {
54
+ "type": "aws_s3_bucket",
55
+ "name": name,
56
+ "attributes": {
57
+ "id": bucket_name,
58
+ "bucket": bucket_name,
59
+ "tags": tags_dict,
60
+ },
61
+ }
62
+ except Exception as e:
63
+ pass
64
+ return live
65
+
66
+ def fetch_live_security_groups(region: str) -> dict:
67
+ ec2 = boto3.client("ec2", region_name=region)
68
+ live = {}
69
+ paginator = ec2.get_paginator("describe_security_groups")
70
+
71
+ try:
72
+ for page in paginator.paginate():
73
+ for sg in page["SecurityGroups"]:
74
+ sg_id = sg["GroupId"]
75
+ sg_name = sg["GroupName"]
76
+
77
+ tags_list = sg.get("Tags", [])
78
+ tags_dict = {t["Key"]: t["Value"] for t in tags_list}
79
+ name_tag = tags_dict.get("Name", sg_name)
80
+
81
+ ingress_rules = []
82
+ for perm in sg.get("IpPermissions", []):
83
+ cidrs = [ip.get("CidrIp") for ip in perm.get("IpRanges", []) if ip.get("CidrIp")]
84
+ if cidrs:
85
+ ingress_rules.append({
86
+ "from_port": perm.get("FromPort", 0),
87
+ "to_port": perm.get("ToPort", 0),
88
+ "protocol": perm.get("IpProtocol", "-1"),
89
+ "cidr_blocks": cidrs
90
+ })
91
+
92
+ egress_rules = []
93
+ for perm in sg.get("IpPermissionsEgress", []):
94
+ cidrs = [ip.get("CidrIp") for ip in perm.get("IpRanges", []) if ip.get("CidrIp")]
95
+ if cidrs:
96
+ egress_rules.append({
97
+ "from_port": perm.get("FromPort", 0),
98
+ "to_port": perm.get("ToPort", 0),
99
+ "protocol": perm.get("IpProtocol", "-1"),
100
+ "cidr_blocks": cidrs
101
+ })
102
+
103
+ live[sg_id] = {
104
+ "type": "aws_security_group",
105
+ "name": name_tag,
106
+ "attributes": {
107
+ "id": sg_id,
108
+ "name": sg_name,
109
+ "description": sg.get("Description", ""),
110
+ "tags": tags_dict,
111
+ "ingress": ingress_rules,
112
+ "egress": egress_rules,
113
+ },
114
+ }
115
+ except Exception as e:
116
+ pass
117
+ return live
118
+
119
+ def fetch_live_rds_instances(region: str = "ap-south-1") -> dict:
120
+ rds = boto3.client("rds", region_name=region)
121
+ live = {}
122
+ paginator = rds.get_paginator("describe_db_instances")
123
+ try:
124
+ for page in paginator.paginate():
125
+ for db in page["DBInstances"]:
126
+ db_id = db.get("DbiResourceId")
127
+ db_name = db["DBInstanceIdentifier"]
128
+ if not db_id:
129
+ continue
130
+ live[db_id] = {
131
+ "type": "aws_db_instance",
132
+ "name": db_name,
133
+ "attributes": {
134
+ "id": db_id,
135
+ "allocated_storage": db.get("AllocatedStorage"),
136
+ "engine": db.get("Engine"),
137
+ "engine_version": db.get("EngineVersion"),
138
+ "instance_class": db.get("DBInstanceClass"),
139
+ "multi_az": db.get("MultiAZ"),
140
+ },
141
+ }
142
+ except Exception as e:
143
+ pass
144
+ return live
145
+
146
+ def fetch_live_lambda_functions(region: str = "ap-south-1") -> dict:
147
+ lambda_client = boto3.client("lambda", region_name=region)
148
+ live = {}
149
+ paginator = lambda_client.get_paginator("list_functions")
150
+ try:
151
+ for page in paginator.paginate():
152
+ for func in page["Functions"]:
153
+ func_name = func["FunctionName"]
154
+ live[func_name] = {
155
+ "type": "aws_lambda_function",
156
+ "name": func_name,
157
+ "attributes": {
158
+ "id": func_name,
159
+ "function_name": func_name,
160
+ "runtime": func.get("Runtime"),
161
+ "handler": func.get("Handler"),
162
+ "memory_size": func.get("MemorySize"),
163
+ "timeout": func.get("Timeout"),
164
+ "role": func.get("Role"),
165
+ },
166
+ }
167
+ except Exception as e:
168
+ pass
169
+ return live
170
+
171
+ def fetch_live_iam_roles(region: str = "ap-south-1") -> dict:
172
+ iam = boto3.client("iam", region_name=region)
173
+ live = {}
174
+ try:
175
+ paginator = iam.get_paginator("list_roles")
176
+ for page in paginator.paginate():
177
+ for role in page["Roles"]:
178
+ role_name = role["RoleName"]
179
+ if role_name.startswith("AWSServiceRoleFor") or role.get("Path", "").startswith("/aws-service-role/"):
180
+ continue
181
+ live[role_name] = {
182
+ "type": "aws_iam_role",
183
+ "name": role_name,
184
+ "attributes": {
185
+ "id": role_name,
186
+ "name": role_name,
187
+ "arn": role["Arn"]
188
+ }
189
+ }
190
+ except Exception as e:
191
+ pass
192
+ return live
193
+
194
+ def get_resource_cost(resource_id: str) -> float:
195
+ try:
196
+ client = boto3.client("ce", region_name="us-east-1")
197
+
198
+ end_date = datetime.today().strftime("%Y-%m-%d")
199
+ start_date = (datetime.today() - timedelta(days=30)).strftime("%Y-%m-%d")
200
+
201
+ response = client.get_cost_and_usage(
202
+ TimePeriod={"Start": start_date, "End": end_date},
203
+ Granularity="MONTHLY",
204
+ Metrics=["UnblendedCost"],
205
+ Filter={
206
+ "Dimensions": {
207
+ "Key": "RESOURCE_ID",
208
+ "Values": [resource_id]
209
+ }
210
+ }
211
+ )
212
+
213
+ usd_cost = float(response["ResultsByTime"][0]["Total"]["UnblendedCost"]["Amount"])
214
+ inr_cost = round(usd_cost * 83.5, 2)
215
+ return inr_cost
216
+ except Exception:
217
+ return 0.0
@@ -0,0 +1,209 @@
1
+ import os
2
+ from datetime import datetime
3
+ from models import DriftResult, DriftType, MONITORED_RESOURCES, MONITORED_ATTRIBUTES, IGNORED_ATTRIBUTES
4
+ from tf_parser import load_terraform_state
5
+ from aws_client import (
6
+ fetch_live_ec2_instances, fetch_live_s3_buckets,
7
+ fetch_live_security_groups, fetch_live_rds_instances,
8
+ fetch_live_lambda_functions, fetch_live_iam_roles,
9
+ get_resource_cost
10
+ )
11
+ from notifications import process_alerts, send_telegram_alert
12
+ from database import save_drift_to_db
13
+ from remediation import process_remediation
14
+ from explain import get_drift_explanation
15
+
16
+ def process_drift_results(resource_id, drift_status, ai_explanation=""):
17
+ if drift_status in ["MODIFIED", "UNMANAGED"]:
18
+ alert_msg = f"DRIFT ALERT\nResource: {resource_id}\nType: {drift_status}\nAction Required!\n\nAI Analysis:\n{ai_explanation}"
19
+ send_telegram_alert(alert_msg)
20
+
21
+ def normalize_sg_rules(rules) -> list:
22
+ normalized = []
23
+ if not isinstance(rules, list):
24
+ return normalized
25
+
26
+ for rule in rules:
27
+ if isinstance(rule, dict):
28
+ cidrs = rule.get('cidr_blocks') or []
29
+ if isinstance(cidrs, list):
30
+ cidrs = tuple(sorted(cidrs))
31
+ else:
32
+ cidrs = tuple()
33
+
34
+ normalized.append({
35
+ 'from_port': rule.get('from_port'),
36
+ 'to_port': rule.get('to_port'),
37
+ 'protocol': rule.get('protocol'),
38
+ 'cidr_blocks': cidrs
39
+ })
40
+
41
+ final_rules = []
42
+ for t in {tuple(sorted(d.items())) for d in normalized}:
43
+ rule_dict = dict(t)
44
+ rule_dict['cidr_blocks'] = list(rule_dict['cidr_blocks'])
45
+ final_rules.append(rule_dict)
46
+
47
+ return final_rules
48
+
49
+ def compare_attributes(tf, live, r_type) -> dict:
50
+ monitored = MONITORED_ATTRIBUTES.get(r_type, set())
51
+ diff = {}
52
+
53
+ for key in monitored:
54
+ tf_val = tf.get(key)
55
+ live_val = live.get(key)
56
+
57
+ if (tf_val in [None, "", [], {}]) and (live_val in [None, "", [], {}]):
58
+ continue
59
+
60
+ if r_type == "aws_security_group" and key in ["ingress", "egress"]:
61
+ tf_norm = normalize_sg_rules(tf_val)
62
+ live_norm = normalize_sg_rules(live_val)
63
+ if tf_norm != live_norm:
64
+ diff[key] = {"terraform": tf_norm, "live": live_norm}
65
+ elif tf_val != live_val:
66
+ diff[key] = {"terraform": tf_val, "live": live_val}
67
+
68
+ return diff
69
+
70
+ def detect_drift(tf_state_path: str, region: str):
71
+ tf_resources = load_terraform_state(tf_state_path)
72
+
73
+ if not tf_resources:
74
+ return [], 0
75
+
76
+ live_ec2 = fetch_live_ec2_instances(region)
77
+ live_s3 = fetch_live_s3_buckets(region)
78
+ live_sg = fetch_live_security_groups(region)
79
+ live_rds = fetch_live_rds_instances(region)
80
+ live_lambda = fetch_live_lambda_functions(region)
81
+ live_iam = fetch_live_iam_roles(region)
82
+
83
+ live_resources = {**live_ec2, **live_s3, **live_sg, **live_rds, **live_lambda, **live_iam}
84
+
85
+ results = []
86
+ all_ids = set(tf_resources) | set(live_resources)
87
+ total_scanned = len(all_ids)
88
+
89
+ for rid in all_ids:
90
+ in_tf = rid in tf_resources
91
+ in_live = rid in live_resources
92
+
93
+ res_name = "Unknown"
94
+ if in_tf:
95
+ res_name = tf_resources[rid]["name"]
96
+ elif in_live:
97
+ res_name = live_resources[rid]["name"]
98
+
99
+ if in_tf and not in_live:
100
+ results.append(DriftResult(
101
+ resource_type=tf_resources[rid]["type"],
102
+ resource_id=rid,
103
+ drift_type=DriftType.MISSING,
104
+ resource_name=res_name,
105
+ tf_attributes=tf_resources[rid]["attributes"]
106
+ ))
107
+ elif in_live and not in_tf:
108
+ results.append(DriftResult(
109
+ resource_type=live_resources[rid]["type"],
110
+ resource_id=rid,
111
+ drift_type=DriftType.UNMANAGED,
112
+ resource_name=res_name,
113
+ live_attributes=live_resources[rid]["attributes"]
114
+ ))
115
+ else:
116
+ diff = compare_attributes(
117
+ tf_resources[rid]["attributes"],
118
+ live_resources[rid]["attributes"],
119
+ tf_resources[rid]["type"]
120
+ )
121
+ if diff:
122
+ results.append(DriftResult(
123
+ resource_type=tf_resources[rid]["type"],
124
+ resource_id=rid,
125
+ drift_type=DriftType.MODIFIED,
126
+ resource_name=res_name,
127
+ tf_attributes=tf_resources[rid]["attributes"],
128
+ live_attributes=live_resources[rid]["attributes"],
129
+ diff=diff
130
+ ))
131
+
132
+ return results, total_scanned
133
+
134
+ def get_severity(r_type, d_type):
135
+ if r_type in ["aws_security_group", "aws_iam_role"]:
136
+ return "CRITICAL"
137
+ if d_type == DriftType.MISSING or r_type == "aws_instance":
138
+ return "HIGH"
139
+ return "MEDIUM"
140
+
141
+ def main():
142
+ tf_state_path = os.environ.get("TF_STATE_PATH", "terraform/terraform.tfstate")
143
+ region = os.environ.get("AWS_DEFAULT_REGION", "ap-south-1")
144
+
145
+ try:
146
+ results, total_scanned = detect_drift(tf_state_path, region)
147
+
148
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S IST")
149
+ total_drift = len(results)
150
+
151
+ print("\n=== DRIFTWATCH SCAN REPORT ===")
152
+ print(f"Scan time: {current_time} | Resources scanned: {total_scanned}\n")
153
+
154
+ if not results:
155
+ print("No drift detected. Infrastructure matches IaC.")
156
+ else:
157
+ crit_count = 0
158
+ high_count = 0
159
+
160
+ for r in results:
161
+ severity = get_severity(r.resource_type, r.drift_type)
162
+
163
+ if severity == "CRITICAL":
164
+ crit_count += 1
165
+ elif severity == "HIGH":
166
+ high_count += 1
167
+
168
+ print(f"[{r.drift_type.value}] {r.resource_type}: {r.resource_id}")
169
+
170
+ ai_text = ""
171
+
172
+ if r.drift_type == DriftType.UNMANAGED and r.resource_type == "aws_instance":
173
+ inst_type = r.live_attributes.get("instance_type", "unknown")
174
+ cost = "{:,.2f}".format(get_resource_cost(r.resource_id))
175
+ print(f" Type: {inst_type} (created manually in console)")
176
+ print(f" Severity: {severity} | Cost: +Rs.{cost}/month (untracked)")
177
+
178
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, r.live_attributes, r.drift_type.value)
179
+
180
+ elif r.diff:
181
+ for attr, vals in r.diff.items():
182
+ print(f" Attribute: {attr}")
183
+ print(f" Terraform: {vals['terraform']}")
184
+ print(f" Live AWS: {vals['live']}")
185
+ print(f" Severity: {severity}")
186
+
187
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, r.diff, r.drift_type.value)
188
+ else:
189
+ print(f" Severity: {severity}")
190
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, {"status": "missing"}, r.drift_type.value)
191
+
192
+ if ai_text:
193
+ print(f" AI Analysis: {ai_text}\n")
194
+ r.ai_analysis = ai_text
195
+ else:
196
+ print("\n")
197
+
198
+ process_drift_results(r.resource_id, r.drift_type.value, ai_text)
199
+
200
+ print(f"Total drift found: {total_drift} resources | CRITICAL: {crit_count} HIGH: {high_count}\n")
201
+
202
+ process_alerts(results)
203
+ save_drift_to_db(results)
204
+
205
+ except Exception as e:
206
+ print(f"Error during execution: {e}")
207
+
208
+ if __name__ == "__main__":
209
+ main()
@@ -0,0 +1,43 @@
1
+ import os
2
+ import json
3
+ import psycopg2
4
+
5
+ def save_drift_to_db(drift_results: list):
6
+ if not drift_results:
7
+ return
8
+
9
+ try:
10
+ conn = psycopg2.connect(
11
+ dbname=os.environ.get("DB_NAME", "driftwatch"),
12
+ user=os.environ.get("DB_USER", "admin"),
13
+ password=os.environ.get("DB_PASSWORD", "admin"),
14
+ host=os.environ.get("DB_HOST", "localhost"),
15
+ port=os.environ.get("DB_PORT", "5432")
16
+ )
17
+ cursor = conn.cursor()
18
+
19
+ cursor.execute('''
20
+ CREATE TABLE IF NOT EXISTS drift_history (
21
+ id SERIAL PRIMARY KEY,
22
+ scan_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
23
+ resource_type VARCHAR(255),
24
+ resource_id VARCHAR(255),
25
+ resource_name VARCHAR(255),
26
+ drift_type VARCHAR(50),
27
+ diff_details TEXT
28
+ )
29
+ ''')
30
+
31
+ for r in drift_results:
32
+ diff_json = json.dumps(r.diff) if r.diff else "{}"
33
+ cursor.execute('''
34
+ INSERT INTO drift_history (resource_type, resource_id, resource_name, drift_type, diff_details)
35
+ VALUES (%s, %s, %s, %s, %s)
36
+ ''', (r.resource_type, r.resource_id, r.resource_name, r.drift_type.value, diff_json))
37
+
38
+ conn.commit()
39
+ cursor.close()
40
+ conn.close()
41
+ print("✅ Drift history saved to PostgreSQL database.")
42
+ except Exception as e:
43
+ print(f"❌ Database error: {e}")
@@ -0,0 +1,47 @@
1
+ import os
2
+ import json
3
+ import requests
4
+
5
+ def get_drift_explanation(resource_type, resource_id, diff_data, drift_type):
6
+ api_key = os.environ.get("GROQ_API_KEY", "").strip()
7
+
8
+ if not api_key:
9
+ return "AI explanation unavailable: GROQ_API_KEY not set in environment."
10
+
11
+ url = "https://api.groq.com/openai/v1/chat/completions"
12
+
13
+ prompt = (
14
+ f"You are a strict AWS DevOps expert. Analyze this infrastructure drift.\n"
15
+ f"Resource Type: {resource_type}\n"
16
+ f"Resource ID: {resource_id}\n"
17
+ f"Drift Type: {drift_type}\n"
18
+ f"Diff Data: {json.dumps(diff_data)}\n\n"
19
+ f"Strict Remediation Rules based on Drift Type:\n"
20
+ f"- UNMANAGED: This resource exists in AWS but is not tracked by Terraform state. The ONLY fix is to provide an exact `terraform import` command. Do NOT create a resource block.\n"
21
+ f"- MISSING: This resource exists in Terraform state but was manually deleted from AWS. The fix is to provide a fresh Terraform resource block to recreate it.\n"
22
+ f"- MODIFIED: This resource attributes in AWS differ from Terraform state. Provide a short Terraform snippet to correct the drift.\n\n"
23
+ f"Provide a brief, plain-English explanation of the security or operational risk (max 3 sentences). "
24
+ f"Then, provide the exact Terraform fix as instructed above."
25
+ )
26
+
27
+ payload = {
28
+ "model": "llama-3.1-8b-instant",
29
+ "messages": [
30
+ {"role": "system", "content": "You are a helpful AWS DevOps assistant."},
31
+ {"role": "user", "content": prompt}
32
+ ],
33
+ "temperature": 0.2
34
+ }
35
+
36
+ headers = {
37
+ "Content-Type": "application/json",
38
+ "Authorization": f"Bearer {api_key}"
39
+ }
40
+
41
+ try:
42
+ response = requests.post(url, json=payload, headers=headers)
43
+ response.raise_for_status()
44
+ data = response.json()
45
+ return data["choices"][0]["message"]["content"]
46
+ except Exception as e:
47
+ return f"AI API Error (Groq): {e}"
@@ -0,0 +1,42 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+
4
+ class DriftType(Enum):
5
+ MISSING = "MISSING"
6
+ MODIFIED = "MODIFIED"
7
+ UNMANAGED = "UNMANAGED"
8
+
9
+ @dataclass
10
+ class DriftResult:
11
+ resource_type: str
12
+ resource_id: str
13
+ drift_type: DriftType
14
+ resource_name: str
15
+ tf_attributes: dict = field(default_factory=dict)
16
+ live_attributes: dict = field(default_factory=dict)
17
+ diff: dict = field(default_factory=dict)
18
+ ai_analysis: str = ""
19
+
20
+ IGNORED_ATTRIBUTES = {
21
+ "aws_instance": {
22
+ "private_ip", "public_ip", "network_interface_id",
23
+ "instance_state", "private_dns", "public_dns", "tags_all"
24
+ },
25
+ "aws_security_group": {"owner_id"},
26
+ "aws_s3_bucket": {
27
+ "arn", "bucket_domain_name", "bucket_regional_domain_name",
28
+ "hosted_zone_id", "region", "request_payer", "tags_all"
29
+ },
30
+ "aws_db_instance": {"engine_version"}
31
+ }
32
+
33
+ MONITORED_ATTRIBUTES = {
34
+ "aws_instance": {"instance_type", "ami", "vpc_security_group_ids"},
35
+ "aws_security_group": {"ingress", "egress", "description"},
36
+ "aws_s3_bucket": {"bucket"},
37
+ "aws_iam_role": set(),
38
+ "aws_db_instance": {"instance_class", "engine", "allocated_storage"},
39
+ "aws_lambda_function": {"runtime", "handler", "memory_size", "timeout"}
40
+ }
41
+
42
+ MONITORED_RESOURCES = list(MONITORED_ATTRIBUTES.keys())
@@ -0,0 +1,121 @@
1
+ import os
2
+ import json
3
+ import urllib.request
4
+ import smtplib
5
+ import requests
6
+ from email.mime.text import MIMEText
7
+ from email.mime.multipart import MIMEMultipart
8
+
9
+ def send_telegram_alert(message: str):
10
+ bot_token = os.environ.get("TELEGRAM_BOT_TOKEN")
11
+ chat_id = os.environ.get("TELEGRAM_CHAT_ID")
12
+
13
+ if not bot_token or not chat_id:
14
+ print("Telegram credentials missing in environment variables.")
15
+ return
16
+
17
+ url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
18
+ payload = {
19
+ "chat_id": chat_id,
20
+ "text": message
21
+ }
22
+
23
+ try:
24
+ response = requests.post(url, json=payload)
25
+ response.raise_for_status()
26
+ print("Telegram alert sent successfully.")
27
+ except Exception as e:
28
+ print(f"Failed to send Telegram alert: {e}")
29
+
30
+ def send_slack_alert(webhook_url: str, drift_results: list):
31
+ if not webhook_url:
32
+ return
33
+
34
+ message_lines = ["*DriftWatch Alert: Infrastructure Drift Detected!*"]
35
+ for r in drift_results:
36
+ line = f"• [{r.drift_type.value}] {r.resource_type}: {r.resource_name} ({r.resource_id})"
37
+ message_lines.append(line)
38
+ if r.diff:
39
+ for attr, vals in r.diff.items():
40
+ message_lines.append(f" - {attr}: Expected '{vals['terraform']}', Found '{vals['live']}'")
41
+ if hasattr(r, 'ai_analysis') and r.ai_analysis:
42
+ message_lines.append(f" - AI Analysis: {r.ai_analysis}")
43
+ message_lines.append("")
44
+
45
+ payload = {"text": "\n".join(message_lines)}
46
+ req = urllib.request.Request(
47
+ webhook_url,
48
+ data=json.dumps(payload).encode('utf-8'),
49
+ headers={'Content-Type': 'application/json'}
50
+ )
51
+
52
+ try:
53
+ urllib.request.urlopen(req)
54
+ print("Slack alert sent successfully.")
55
+ except Exception as e:
56
+ print(f"Failed to send Slack alert: {e}")
57
+
58
+ def send_email_alert(smtp_server: str, smtp_port: int, sender_email: str, sender_password: str, recipient_email: str, drift_results: list):
59
+ if not all([smtp_server, sender_email, sender_password, recipient_email]):
60
+ return
61
+
62
+ msg = MIMEMultipart()
63
+ msg['From'] = sender_email
64
+ msg['To'] = recipient_email
65
+ msg['Subject'] = "DriftWatch Alert: Infrastructure Drift Detected"
66
+
67
+ body_lines = ["DriftWatch has detected changes in your infrastructure:\n"]
68
+ for r in drift_results:
69
+ line = f"[{r.drift_type.value}] {r.resource_type}: {r.resource_name} ({r.resource_id})"
70
+ body_lines.append(line)
71
+ if r.diff:
72
+ for attr, vals in r.diff.items():
73
+ body_lines.append(f" - {attr}: Expected '{vals['terraform']}', Found '{vals['live']}'")
74
+ if hasattr(r, 'ai_analysis') and r.ai_analysis:
75
+ body_lines.append(f"\n AI Analysis:\n {r.ai_analysis}\n")
76
+ else:
77
+ body_lines.append("")
78
+
79
+ msg.attach(MIMEText("\n".join(body_lines), 'plain'))
80
+
81
+ try:
82
+ server = smtplib.SMTP(smtp_server, smtp_port)
83
+ server.starttls()
84
+ server.login(sender_email, sender_password)
85
+ server.send_message(msg)
86
+ server.quit()
87
+ print("Email alert sent successfully.")
88
+ except Exception as e:
89
+ print(f"Failed to send Email alert: {e}")
90
+
91
+ def process_alerts(drift_results: list):
92
+ if not drift_results:
93
+ return
94
+
95
+ slack_webhook = os.environ.get("SLACK_WEBHOOK_URL")
96
+ smtp_server = os.environ.get("SMTP_SERVER", "smtp.gmail.com")
97
+ smtp_port = int(os.environ.get("SMTP_PORT", 587))
98
+ sender_email = os.environ.get("SENDER_EMAIL")
99
+ sender_password = os.environ.get("SENDER_PASSWORD")
100
+ recipient_email = os.environ.get("RECIPIENT_EMAIL")
101
+ bot_token = os.environ.get("TELEGRAM_BOT_TOKEN")
102
+ chat_id = os.environ.get("TELEGRAM_CHAT_ID")
103
+
104
+ if slack_webhook:
105
+ send_slack_alert(slack_webhook, drift_results)
106
+
107
+ if sender_email and sender_password and recipient_email:
108
+ send_email_alert(smtp_server, smtp_port, sender_email, sender_password, recipient_email, drift_results)
109
+
110
+ if bot_token and chat_id:
111
+ message_lines = ["DriftWatch Alert Summary:"]
112
+ for r in drift_results:
113
+ line = f"[{r.drift_type.value}] {r.resource_type}: {r.resource_name} ({r.resource_id})"
114
+ message_lines.append(line)
115
+
116
+ telegram_message = "\n".join(message_lines)
117
+
118
+ if len(telegram_message) > 4000:
119
+ telegram_message = telegram_message[:4000] + "\n...[TRUNCATED]"
120
+
121
+ send_telegram_alert(telegram_message)
@@ -0,0 +1,193 @@
1
+ import boto3
2
+ import os
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
+ if not confirm_action(f"Change EC2 {instance_id} instance type to {expected_type}", env, True):
34
+ print(f"⏭️ Skipped remediation for EC2 {instance_id}")
35
+ return
36
+
37
+ ec2 = boto3.client('ec2', region_name=region)
38
+ print(f"Stopping instance {instance_id} for remediation...")
39
+ ec2.stop_instances(InstanceIds=[instance_id])
40
+ waiter = ec2.get_waiter('instance_stopped')
41
+ waiter.wait(InstanceIds=[instance_id])
42
+ print(f"Modifying instance type to {expected_type}...")
43
+ ec2.modify_instance_attribute(
44
+ InstanceId=instance_id,
45
+ InstanceType={'Value': expected_type}
46
+ )
47
+ print(f"Restarting instance {instance_id}...")
48
+ ec2.start_instances(InstanceIds=[instance_id])
49
+ print(f"✅ [REMEDIATED] Successfully remediated {instance_id} back to {expected_type}")
50
+
51
+ def remediate_security_group(region: str, sg_id: str, diff_data: dict, env: str):
52
+ ec2 = boto3.client('ec2', region_name=region)
53
+ expected_ingress = diff_data.get("ingress", {}).get("terraform", [])
54
+ live_ingress = diff_data.get("ingress", {}).get("live", [])
55
+
56
+ print(f"Checking Security Group {sg_id} for unauthorized rules...")
57
+ for live_rule in live_ingress:
58
+ is_authorized = False
59
+ for exp_rule in expected_ingress:
60
+ if (live_rule.get('from_port') == exp_rule.get('from_port') and
61
+ live_rule.get('to_port') == exp_rule.get('to_port') and
62
+ live_rule.get('protocol') == exp_rule.get('protocol')):
63
+ is_authorized = True
64
+ break
65
+
66
+ if not is_authorized:
67
+ if confirm_action(f"Revoke unauthorized rule (Port {live_rule.get('from_port')}) in {sg_id}", env, False):
68
+ print(f"Revoking unauthorized rule: {live_rule}")
69
+ try:
70
+ ec2.revoke_security_group_ingress(
71
+ GroupId=sg_id,
72
+ IpPermissions=[{
73
+ 'IpProtocol': live_rule['protocol'],
74
+ 'FromPort': live_rule['from_port'],
75
+ 'ToPort': live_rule['to_port'],
76
+ 'IpRanges': [{'CidrIp': cidr} for cidr in live_rule.get('cidr_blocks', []) if cidr]
77
+ }]
78
+ )
79
+ print(f"✅ [REMEDIATED] Successfully removed unauthorized Inbound Rule from {sg_id}")
80
+ except Exception as e:
81
+ print(f"❌ Failed to revoke rule in {sg_id}: {e}")
82
+ else:
83
+ print(f"⏭️ Skipped revoking rule for Port {live_rule.get('from_port')}")
84
+
85
+ print(f"Checking Security Group {sg_id} for missing IaC rules...")
86
+ for exp_rule in expected_ingress:
87
+ is_missing = True
88
+ for live_rule in live_ingress:
89
+ if (live_rule.get('from_port') == exp_rule.get('from_port') and
90
+ live_rule.get('to_port') == exp_rule.get('to_port') and
91
+ live_rule.get('protocol') == exp_rule.get('protocol')):
92
+ is_missing = False
93
+ break
94
+
95
+ if is_missing:
96
+ if confirm_action(f"Restore missing IaC rule (Port {exp_rule.get('from_port')}) in {sg_id}", env, False):
97
+ print(f"Restoring missing IaC rule: Port {exp_rule.get('from_port')}")
98
+ try:
99
+ ec2.authorize_security_group_ingress(
100
+ GroupId=sg_id,
101
+ IpPermissions=[{
102
+ 'IpProtocol': exp_rule['protocol'],
103
+ 'FromPort': exp_rule['from_port'],
104
+ 'ToPort': exp_rule['to_port'],
105
+ 'IpRanges': [{'CidrIp': cidr} for cidr in exp_rule.get('cidr_blocks', []) if cidr]
106
+ }]
107
+ )
108
+ print(f"✅ [REMEDIATED] Successfully restored missing IaC Inbound Rule to {sg_id}")
109
+ except Exception as e:
110
+ print(f"❌ Failed to restore rule in {sg_id}: {e}")
111
+ else:
112
+ print(f"⏭️ Skipped restoring rule for Port {exp_rule.get('from_port')}")
113
+
114
+ print(f"Completed remediation check for Security Group {sg_id}")
115
+
116
+ def remediate_s3_bucket(bucket_name: str, env: str):
117
+ if not confirm_action(f"Enforce strict Public Access Block on S3 Bucket '{bucket_name}'", env, False):
118
+ print(f"⏭️ Skipped remediation for S3 bucket {bucket_name}")
119
+ return
120
+
121
+ s3 = boto3.client('s3')
122
+ print(f"Remediating S3 Bucket {bucket_name} by enforcing public access block...")
123
+ try:
124
+ s3.put_public_access_block(
125
+ Bucket=bucket_name,
126
+ PublicAccessBlockConfiguration={
127
+ 'BlockPublicAcls': True,
128
+ 'IgnorePublicAcls': True,
129
+ 'BlockPublicPolicy': True,
130
+ 'RestrictPublicBuckets': True
131
+ }
132
+ )
133
+ print(f"✅ [REMEDIATED] Successfully blocked public access for {bucket_name}")
134
+ except Exception as e:
135
+ print(f"❌ Failed to remediate S3 bucket {bucket_name}: {e}")
136
+
137
+ def remediate_iam_role(role_name: str, diff_data: dict, env: str):
138
+ iam = boto3.client('iam')
139
+ expected_policies = diff_data.get("attached_policies", {}).get("terraform", [])
140
+ live_policies = diff_data.get("attached_policies", {}).get("live", [])
141
+
142
+ print(f"Checking IAM Role {role_name} for unauthorized policies...")
143
+ for live_policy in live_policies:
144
+ if live_policy not in expected_policies:
145
+ if confirm_action(f"Detach unauthorized policy '{live_policy}' from Role '{role_name}'", env, False):
146
+ print(f"Detaching unauthorized policy: {live_policy}")
147
+ try:
148
+ iam.detach_role_policy(
149
+ RoleName=role_name,
150
+ PolicyArn=live_policy
151
+ )
152
+ print(f"✅ [REMEDIATED] Successfully detached {live_policy} from {role_name}")
153
+ except Exception as e:
154
+ print(f"❌ Failed to detach policy from {role_name}: {e}")
155
+ else:
156
+ print(f"⏭️ Skipped detaching policy {live_policy}")
157
+
158
+ def process_remediation(drift_results: list):
159
+ region = os.environ.get("AWS_DEFAULT_REGION", "ap-south-1")
160
+ for result in drift_results:
161
+ attrs = result.live_attributes or result.tf_attributes or {}
162
+ tags = attrs.get('tags', {})
163
+ env = get_environment_tag(tags)
164
+
165
+ if result.drift_type.value == "MODIFIED":
166
+ if result.resource_type == "aws_instance" and "instance_type" in result.diff:
167
+ expected_type = result.diff["instance_type"]["terraform"]
168
+ print(f"\n--- Drift Detected: EC2 Instance ({result.resource_id}) ---")
169
+ try:
170
+ remediate_ec2_instance_type(region, result.resource_id, expected_type, env)
171
+ except Exception as e:
172
+ print(f"Remediation failed for {result.resource_id}: {e}")
173
+
174
+ elif result.resource_type == "aws_security_group" and "ingress" in result.diff:
175
+ print(f"\n--- Drift Detected: Security Group ({result.resource_id}) ---")
176
+ try:
177
+ remediate_security_group(region, result.resource_id, result.diff, env)
178
+ except Exception as e:
179
+ print(f"Remediation failed for {result.resource_id}: {e}")
180
+
181
+ elif result.resource_type == "aws_s3_bucket":
182
+ print(f"\n--- Drift Detected: S3 Bucket ({result.resource_id}) ---")
183
+ try:
184
+ remediate_s3_bucket(result.resource_id, env)
185
+ except Exception as e:
186
+ print(f"Remediation failed for {result.resource_id}: {e}")
187
+
188
+ elif result.resource_type == "aws_iam_role" and "attached_policies" in result.diff:
189
+ print(f"\n--- Drift Detected: IAM Role ({result.resource_id}) ---")
190
+ try:
191
+ remediate_iam_role(result.resource_id, result.diff, env)
192
+ except Exception as e:
193
+ print(f"Remediation failed for {result.resource_id}: {e}")
@@ -0,0 +1,40 @@
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
+
11
+ resources = {}
12
+ for resource in state.get("resources", []):
13
+ r_type = resource["type"]
14
+
15
+ if r_type in ["archive_file", "aws_iam_role_policy_attachment"]:
16
+ continue
17
+
18
+ for instance in resource.get("instances", []):
19
+ attrs = instance.get("attributes", {})
20
+ resource_id = attrs.get("id")
21
+
22
+ tags = attrs.get("tags", {})
23
+ if tags and tags.get("Name"):
24
+ name = tags.get("Name")
25
+ elif r_type == "aws_lambda_function":
26
+ name = attrs.get("function_name", "Unknown")
27
+ elif r_type == "aws_db_instance":
28
+ name = attrs.get("identifier", "Unknown")
29
+ elif r_type == "aws_iam_role":
30
+ name = attrs.get("name", "Unknown")
31
+ else:
32
+ name = "Unknown"
33
+
34
+ if resource_id:
35
+ resources[resource_id] = {
36
+ "type": r_type,
37
+ "name": name,
38
+ "attributes": attrs
39
+ }
40
+ return resources
File without changes
@@ -0,0 +1,156 @@
1
+ import typer
2
+ import os
3
+ import sys
4
+ from datetime import datetime
5
+
6
+ base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
7
+ sys.path.append(base_dir)
8
+ sys.path.append(os.path.join(base_dir, "drift_engine"))
9
+
10
+ env_file = os.path.join(base_dir, ".env")
11
+ if os.path.exists(env_file):
12
+ with open(env_file, "r") as f:
13
+ for line in f:
14
+ stripped_line = line.strip()
15
+ if stripped_line and not stripped_line.startswith("#"):
16
+ if "=" in stripped_line:
17
+ k, v = stripped_line.split("=", 1)
18
+ os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
19
+
20
+ from drift_engine.core import detect_drift, get_severity, get_resource_cost, process_drift_results
21
+ from drift_engine.explain import get_drift_explanation
22
+ from drift_engine.models import DriftType
23
+ from drift_engine.notifications import process_alerts
24
+ from drift_engine.database import save_drift_to_db
25
+ from drift_engine.remediation import process_remediation
26
+
27
+ app = typer.Typer()
28
+
29
+ SEVERITY_RANK = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
30
+
31
+ @app.command()
32
+ def scan(
33
+ state: str = typer.Option("terraform/terraform.tfstate"),
34
+ region: str = typer.Option("ap-south-1"),
35
+ fail_on: str = typer.Option(None)
36
+ ):
37
+ typer.echo("Scanning AWS Infrastructure...\n")
38
+ os.environ["TF_STATE_PATH"] = state
39
+ os.environ["AWS_DEFAULT_REGION"] = region
40
+
41
+ try:
42
+ results, total_scanned = detect_drift(state, region)
43
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S IST")
44
+ total_drift = len(results)
45
+
46
+ typer.echo("=== DRIFTWATCH SCAN REPORT ===")
47
+ typer.echo(f"Scan time: {current_time} | Resources scanned: {total_scanned}\n")
48
+
49
+ if not results:
50
+ typer.secho("No drift detected. Infrastructure matches IaC.", fg=typer.colors.GREEN)
51
+ return
52
+
53
+ highest_severity_found = "LOW"
54
+ crit_count = 0
55
+ high_count = 0
56
+
57
+ for r in results:
58
+ severity = get_severity(r.resource_type, r.drift_type)
59
+
60
+ if SEVERITY_RANK.get(severity, 0) > SEVERITY_RANK.get(highest_severity_found, 0):
61
+ highest_severity_found = severity
62
+
63
+ if severity == "CRITICAL":
64
+ crit_count += 1
65
+ color = typer.colors.RED
66
+ elif severity == "HIGH":
67
+ high_count += 1
68
+ color = typer.colors.YELLOW
69
+ else:
70
+ color = typer.colors.BLUE
71
+
72
+ typer.secho(f"[{r.drift_type.value}] {r.resource_type}: {r.resource_id}", fg=color, bold=True)
73
+ ai_text = ""
74
+
75
+ if r.drift_type == DriftType.UNMANAGED and r.resource_type == "aws_instance":
76
+ cost = "{:,.2f}".format(get_resource_cost(r.resource_id))
77
+ inst_type = r.live_attributes.get("instance_type", "unknown")
78
+ typer.echo(f" Type: {inst_type} (created manually in console)")
79
+ typer.echo(f" Severity: {severity} | Cost: +Rs.{cost}/month (untracked)")
80
+
81
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, r.live_attributes, r.drift_type.value)
82
+
83
+ elif r.diff:
84
+ typer.echo(f" Severity: {severity}")
85
+ for attr, vals in r.diff.items():
86
+ typer.echo(f" Attribute: {attr}")
87
+ typer.echo(f" Terraform: {vals['terraform']}")
88
+ typer.echo(f" Live AWS: {vals['live']}")
89
+
90
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, r.diff, r.drift_type.value)
91
+ else:
92
+ typer.echo(f" Severity: {severity}")
93
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, {"status": "missing"}, r.drift_type.value)
94
+
95
+ if ai_text:
96
+ typer.echo(f" AI Analysis: {ai_text}\n")
97
+ r.ai_analysis = ai_text
98
+ else:
99
+ typer.echo("\n")
100
+
101
+ process_drift_results(r.resource_id, r.drift_type.value, ai_text)
102
+
103
+ typer.echo(f"Total drift found: {total_drift} resources | CRITICAL: {crit_count} HIGH: {high_count}\n")
104
+
105
+ process_alerts(results)
106
+ save_drift_to_db(results)
107
+
108
+ if results:
109
+ typer.echo("Tip: run 'driftwatch remediate <resource_id>' to fix a specific resource.\n")
110
+
111
+ if fail_on and SEVERITY_RANK.get(highest_severity_found, 0) >= SEVERITY_RANK.get(fail_on.upper(), 0):
112
+ typer.secho(
113
+ f"\nBUILD FAILED: highest severity found is {highest_severity_found} (gate: {fail_on.upper()})",
114
+ fg=typer.colors.RED, bold=True,
115
+ )
116
+ raise typer.Exit(code=1)
117
+
118
+ except typer.Exit:
119
+ raise
120
+ except Exception as e:
121
+ typer.secho(f"Error during execution: {e}", fg=typer.colors.RED)
122
+ raise typer.Exit(code=1)
123
+
124
+ @app.command()
125
+ def explain(resource_id: str):
126
+ typer.echo(f"Fetching AI explanation for {resource_id}...")
127
+ ai_text = get_drift_explanation("unknown", resource_id, {}, "UNKNOWN")
128
+ typer.echo(f"\nAI Analysis:\n{ai_text}")
129
+
130
+ @app.command()
131
+ def remediate(
132
+ resource_id: str,
133
+ state: str = typer.Option("terraform/terraform.tfstate"),
134
+ region: str = typer.Option("ap-south-1"),
135
+ dry_run: bool = typer.Option(True, "--dry-run/--apply"),
136
+ ):
137
+ results, _ = detect_drift(state, region)
138
+ match = [r for r in results if r.resource_id == resource_id]
139
+
140
+ if not match:
141
+ typer.secho(f"No current drift found for {resource_id}. Run 'driftwatch scan' first.", fg=typer.colors.YELLOW)
142
+ raise typer.Exit(code=1)
143
+
144
+ r = match[0]
145
+ typer.secho(f"[{r.drift_type.value}] {r.resource_type}: {r.resource_id}", bold=True)
146
+ for attr, vals in (r.diff or {}).items():
147
+ typer.echo(f" {attr}: terraform={vals['terraform']} live={vals['live']}")
148
+
149
+ if dry_run:
150
+ typer.secho("\n[DRY RUN] No changes made. Re-run with --apply to remediate.", fg=typer.colors.BLUE)
151
+ return
152
+
153
+ process_remediation(match)
154
+
155
+ if __name__ == "__main__":
156
+ app()
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: driftwatch-cli
3
+ Version: 3.0.0
4
+ Summary: CLI tool that detects Terraform infrastructure drift against live AWS, explains it with AI, and guides remediation.
5
+ Requires-Python: >=3.10
6
+ License-File: LICENSE
7
+ Requires-Dist: boto3
8
+ Requires-Dist: typer
9
+ Requires-Dist: groq
10
+ Requires-Dist: python-telegram-bot
11
+ Requires-Dist: psycopg2-binary
12
+ Requires-Dist: requests
13
+ Dynamic: license-file
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ pyproject.toml
3
+ drift_engine/__init__.py
4
+ drift_engine/aws_client.py
5
+ drift_engine/core.py
6
+ drift_engine/database.py
7
+ drift_engine/explain.py
8
+ drift_engine/models.py
9
+ drift_engine/notifications.py
10
+ drift_engine/remediation.py
11
+ drift_engine/tf_parser.py
12
+ driftwatch/__init__.py
13
+ driftwatch/cli.py
14
+ driftwatch_cli.egg-info/PKG-INFO
15
+ driftwatch_cli.egg-info/SOURCES.txt
16
+ driftwatch_cli.egg-info/dependency_links.txt
17
+ driftwatch_cli.egg-info/entry_points.txt
18
+ driftwatch_cli.egg-info/requires.txt
19
+ driftwatch_cli.egg-info/top_level.txt
20
+ tests/test_diff_engine.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ driftwatch = driftwatch.cli:app
@@ -0,0 +1,6 @@
1
+ boto3
2
+ typer
3
+ groq
4
+ python-telegram-bot
5
+ psycopg2-binary
6
+ requests
@@ -0,0 +1,2 @@
1
+ drift_engine
2
+ driftwatch
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "driftwatch-cli"
7
+ version = "3.0.0"
8
+ description = "CLI tool that detects Terraform infrastructure drift against live AWS, explains it with AI, and guides remediation."
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "boto3",
12
+ "typer",
13
+ "groq",
14
+ "python-telegram-bot",
15
+ "psycopg2-binary",
16
+ "requests"
17
+ ]
18
+
19
+ [project.scripts]
20
+ driftwatch = "driftwatch.cli:app"
21
+
22
+ [tool.setuptools.packages.find]
23
+ include = ["driftwatch*", "drift_engine*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,39 @@
1
+ from drift_engine.core import compare_attributes, DriftType
2
+
3
+ def test_ignored_attributes_do_not_trigger_drift():
4
+ tf = {"id": "i-123", "private_ip": "10.0.0.5"}
5
+ live = {"id": "i-123", "private_ip": "10.0.0.9"}
6
+
7
+ diff = compare_attributes(tf, live, "aws_instance")
8
+
9
+ assert diff == {}
10
+
11
+ def test_real_attribute_change_is_detected():
12
+ tf = {"id": "i-123", "instance_type": "t3.micro"}
13
+ live = {"id": "i-123", "instance_type": "t3.small"}
14
+
15
+ diff = compare_attributes(tf, live, "aws_instance")
16
+
17
+ assert "instance_type" in diff
18
+ assert diff["instance_type"]["terraform"] == "t3.micro"
19
+ assert diff["instance_type"]["live"] == "t3.small"
20
+
21
+ def test_security_group_changes_are_detected():
22
+ tf_attrs = {"id": "sg-123", "description": "Managed by TF"}
23
+ live_attrs = {"id": "sg-123", "description": "Manual edit in AWS"}
24
+
25
+ diff = compare_attributes(tf_attrs, live_attrs, "aws_security_group")
26
+
27
+ assert "description" in diff
28
+ assert diff["description"]["terraform"] == "Managed by TF"
29
+ assert diff["description"]["live"] == "Manual edit in AWS"
30
+
31
+ def test_iam_role_changes_are_detected():
32
+ tf_attrs = {"id": "MyRole", "path": "/"}
33
+ live_attrs = {"id": "MyRole", "path": "/service-role/"}
34
+
35
+ diff = compare_attributes(tf_attrs, live_attrs, "aws_iam_role")
36
+
37
+ assert "path" in diff
38
+ assert diff["path"]["terraform"] == "/"
39
+ assert diff["path"]["live"] == "/service-role/"