driftwatch-cli 3.0.0__tar.gz → 3.0.1__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.
Files changed (24) hide show
  1. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/PKG-INFO +5 -1
  2. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/aws_client.py +56 -43
  3. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/core.py +34 -88
  4. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/database.py +16 -5
  5. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/models.py +3 -3
  6. driftwatch_cli-3.0.1/drift_engine/remediation.py +224 -0
  7. driftwatch_cli-3.0.1/drift_engine/tf_parser.py +67 -0
  8. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch/cli.py +48 -30
  9. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/PKG-INFO +5 -1
  10. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/requires.txt +1 -0
  11. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/pyproject.toml +10 -2
  12. driftwatch_cli-3.0.0/drift_engine/remediation.py +0 -193
  13. driftwatch_cli-3.0.0/drift_engine/tf_parser.py +0 -40
  14. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/LICENSE +0 -0
  15. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/__init__.py +0 -0
  16. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/explain.py +0 -0
  17. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/notifications.py +0 -0
  18. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch/__init__.py +0 -0
  19. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/SOURCES.txt +0 -0
  20. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/dependency_links.txt +0 -0
  21. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/entry_points.txt +0 -0
  22. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/top_level.txt +0 -0
  23. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/setup.cfg +0 -0
  24. {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/tests/test_diff_engine.py +0 -0
@@ -1,7 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driftwatch-cli
3
- Version: 3.0.0
3
+ Version: 3.0.1
4
4
  Summary: CLI tool that detects Terraform infrastructure drift against live AWS, explains it with AI, and guides remediation.
5
+ Author: Nitin Gupta
6
+ Project-URL: Homepage, https://github.com/hastagnitin/driftwatch
7
+ Project-URL: Repository, https://github.com/hastagnitin/driftwatch
5
8
  Requires-Python: >=3.10
6
9
  License-File: LICENSE
7
10
  Requires-Dist: boto3
@@ -10,4 +13,5 @@ Requires-Dist: groq
10
13
  Requires-Dist: python-telegram-bot
11
14
  Requires-Dist: psycopg2-binary
12
15
  Requires-Dist: requests
16
+ Requires-Dist: python-dotenv
13
17
  Dynamic: license-file
@@ -1,37 +1,42 @@
1
+ import sys
1
2
  import boto3
2
3
  from datetime import datetime, timedelta
3
4
 
4
5
  def fetch_live_ec2_instances(region: str) -> dict:
5
6
  ec2 = boto3.client("ec2", region_name=region)
6
7
  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
- }
8
+ try:
9
+ paginator = ec2.get_paginator("describe_instances")
10
+
11
+ for page in paginator.paginate():
12
+ for reservation in page["Reservations"]:
13
+ for instance in reservation["Instances"]:
14
+ if instance["State"]["Name"] == "terminated":
15
+ continue
16
+
17
+ tags_list = instance.get("Tags", [])
18
+ tags_dict = {t["Key"]: t["Value"] for t in tags_list}
19
+ name = tags_dict.get("Name", "Unknown")
20
+
21
+ sg_ids = []
22
+ for sg in instance.get("SecurityGroups", []):
23
+ sg_ids.append(sg.get("GroupId"))
24
+ sg_ids.sort()
25
+
26
+ live[instance["InstanceId"]] = {
27
+ "type": "aws_instance",
28
+ "name": name,
29
+ "attributes": {
30
+ "id": instance["InstanceId"],
31
+ "instance_type": instance["InstanceType"],
32
+ "ami": instance["ImageId"],
33
+ "tags": tags_dict,
34
+ "vpc_security_group_ids": sg_ids
35
+ },
36
+ }
37
+ except Exception as e:
38
+ print(f"Failed to fetch aws_instance: {e}", file=sys.stderr)
39
+ return None
35
40
  return live
36
41
 
37
42
  def fetch_live_s3_buckets(region: str) -> dict:
@@ -60,15 +65,15 @@ def fetch_live_s3_buckets(region: str) -> dict:
60
65
  },
61
66
  }
62
67
  except Exception as e:
63
- pass
68
+ print(f"Failed to fetch aws_s3_bucket: {e}", file=sys.stderr)
69
+ return None
64
70
  return live
65
71
 
66
72
  def fetch_live_security_groups(region: str) -> dict:
67
73
  ec2 = boto3.client("ec2", region_name=region)
68
74
  live = {}
69
- paginator = ec2.get_paginator("describe_security_groups")
70
-
71
75
  try:
76
+ paginator = ec2.get_paginator("describe_security_groups")
72
77
  for page in paginator.paginate():
73
78
  for sg in page["SecurityGroups"]:
74
79
  sg_id = sg["GroupId"]
@@ -113,14 +118,15 @@ def fetch_live_security_groups(region: str) -> dict:
113
118
  },
114
119
  }
115
120
  except Exception as e:
116
- pass
121
+ print(f"Failed to fetch aws_security_group: {e}", file=sys.stderr)
122
+ return None
117
123
  return live
118
124
 
119
- def fetch_live_rds_instances(region: str = "ap-south-1") -> dict:
125
+ def fetch_live_rds_instances(region: str) -> dict:
120
126
  rds = boto3.client("rds", region_name=region)
121
127
  live = {}
122
- paginator = rds.get_paginator("describe_db_instances")
123
128
  try:
129
+ paginator = rds.get_paginator("describe_db_instances")
124
130
  for page in paginator.paginate():
125
131
  for db in page["DBInstances"]:
126
132
  db_id = db.get("DbiResourceId")
@@ -140,14 +146,15 @@ def fetch_live_rds_instances(region: str = "ap-south-1") -> dict:
140
146
  },
141
147
  }
142
148
  except Exception as e:
143
- pass
149
+ print(f"Failed to fetch aws_db_instance: {e}", file=sys.stderr)
150
+ return None
144
151
  return live
145
152
 
146
- def fetch_live_lambda_functions(region: str = "ap-south-1") -> dict:
153
+ def fetch_live_lambda_functions(region: str) -> dict:
147
154
  lambda_client = boto3.client("lambda", region_name=region)
148
155
  live = {}
149
- paginator = lambda_client.get_paginator("list_functions")
150
156
  try:
157
+ paginator = lambda_client.get_paginator("list_functions")
151
158
  for page in paginator.paginate():
152
159
  for func in page["Functions"]:
153
160
  func_name = func["FunctionName"]
@@ -165,10 +172,11 @@ def fetch_live_lambda_functions(region: str = "ap-south-1") -> dict:
165
172
  },
166
173
  }
167
174
  except Exception as e:
168
- pass
175
+ print(f"Failed to fetch aws_lambda_function: {e}", file=sys.stderr)
176
+ return None
169
177
  return live
170
178
 
171
- def fetch_live_iam_roles(region: str = "ap-south-1") -> dict:
179
+ def fetch_live_iam_roles(region: str) -> dict:
172
180
  iam = boto3.client("iam", region_name=region)
173
181
  live = {}
174
182
  try:
@@ -178,17 +186,23 @@ def fetch_live_iam_roles(region: str = "ap-south-1") -> dict:
178
186
  role_name = role["RoleName"]
179
187
  if role_name.startswith("AWSServiceRoleFor") or role.get("Path", "").startswith("/aws-service-role/"):
180
188
  continue
189
+
190
+ policies = iam.list_attached_role_policies(RoleName=role_name)
191
+ attached_policies = [p["PolicyArn"] for p in policies.get("AttachedPolicies", [])]
192
+
181
193
  live[role_name] = {
182
194
  "type": "aws_iam_role",
183
195
  "name": role_name,
184
196
  "attributes": {
185
197
  "id": role_name,
186
198
  "name": role_name,
187
- "arn": role["Arn"]
199
+ "arn": role["Arn"],
200
+ "attached_policies": attached_policies
188
201
  }
189
202
  }
190
203
  except Exception as e:
191
- pass
204
+ print(f"Failed to fetch aws_iam_role: {e}", file=sys.stderr)
205
+ return None
192
206
  return live
193
207
 
194
208
  def get_resource_cost(resource_id: str) -> float:
@@ -211,7 +225,6 @@ def get_resource_cost(resource_id: str) -> float:
211
225
  )
212
226
 
213
227
  usd_cost = float(response["ResultsByTime"][0]["Total"]["UnblendedCost"]["Amount"])
214
- inr_cost = round(usd_cost * 83.5, 2)
215
- return inr_cost
228
+ return usd_cost
216
229
  except Exception:
217
230
  return 0.0
@@ -1,22 +1,11 @@
1
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 (
2
+ from drift_engine.models import DriftResult, DriftType, MONITORED_RESOURCES, MONITORED_ATTRIBUTES, IGNORED_ATTRIBUTES
3
+ from drift_engine.tf_parser import load_terraform_state
4
+ from drift_engine.aws_client import (
6
5
  fetch_live_ec2_instances, fetch_live_s3_buckets,
7
6
  fetch_live_security_groups, fetch_live_rds_instances,
8
- fetch_live_lambda_functions, fetch_live_iam_roles,
9
- get_resource_cost
7
+ fetch_live_lambda_functions, fetch_live_iam_roles
10
8
  )
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
9
 
21
10
  def normalize_sg_rules(rules) -> list:
22
11
  normalized = []
@@ -72,14 +61,39 @@ def detect_drift(tf_state_path: str, region: str):
72
61
 
73
62
  if not tf_resources:
74
63
  return [], 0
75
-
64
+
65
+ failed_types = set()
66
+
76
67
  live_ec2 = fetch_live_ec2_instances(region)
68
+ if live_ec2 is None:
69
+ failed_types.add("aws_instance")
70
+ live_ec2 = {}
71
+
77
72
  live_s3 = fetch_live_s3_buckets(region)
73
+ if live_s3 is None:
74
+ failed_types.add("aws_s3_bucket")
75
+ live_s3 = {}
76
+
78
77
  live_sg = fetch_live_security_groups(region)
78
+ if live_sg is None:
79
+ failed_types.add("aws_security_group")
80
+ live_sg = {}
81
+
79
82
  live_rds = fetch_live_rds_instances(region)
83
+ if live_rds is None:
84
+ failed_types.add("aws_db_instance")
85
+ live_rds = {}
86
+
80
87
  live_lambda = fetch_live_lambda_functions(region)
88
+ if live_lambda is None:
89
+ failed_types.add("aws_lambda_function")
90
+ live_lambda = {}
91
+
81
92
  live_iam = fetch_live_iam_roles(region)
82
-
93
+ if live_iam is None:
94
+ failed_types.add("aws_iam_role")
95
+ live_iam = {}
96
+
83
97
  live_resources = {**live_ec2, **live_s3, **live_sg, **live_rds, **live_lambda, **live_iam}
84
98
 
85
99
  results = []
@@ -97,6 +111,8 @@ def detect_drift(tf_state_path: str, region: str):
97
111
  res_name = live_resources[rid]["name"]
98
112
 
99
113
  if in_tf and not in_live:
114
+ if tf_resources[rid]["type"] in failed_types:
115
+ continue
100
116
  results.append(DriftResult(
101
117
  resource_type=tf_resources[rid]["type"],
102
118
  resource_id=rid,
@@ -136,74 +152,4 @@ def get_severity(r_type, d_type):
136
152
  return "CRITICAL"
137
153
  if d_type == DriftType.MISSING or r_type == "aws_instance":
138
154
  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()
155
+ return "MEDIUM"
@@ -5,14 +5,25 @@ import psycopg2
5
5
  def save_drift_to_db(drift_results: list):
6
6
  if not drift_results:
7
7
  return
8
+
9
+ db_user = os.environ.get("DB_USER")
10
+ db_password = os.environ.get("DB_PASSWORD")
11
+ db_name = os.environ.get("DB_NAME", "driftwatch")
12
+ db_host = os.environ.get("DB_HOST", "localhost")
13
+ db_port = os.environ.get("DB_PORT", "5432")
14
+
15
+ # Security Fix: Check if credentials exist before connecting
16
+ if not db_user or not db_password:
17
+ print("⚠️ DB_USER or DB_PASSWORD not found in environment. Skipping database save.")
18
+ return
8
19
 
9
20
  try:
10
21
  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")
22
+ dbname=db_name,
23
+ user=db_user,
24
+ password=db_password,
25
+ host=db_host,
26
+ port=db_port
16
27
  )
17
28
  cursor = conn.cursor()
18
29
 
@@ -31,12 +31,12 @@ IGNORED_ATTRIBUTES = {
31
31
  }
32
32
 
33
33
  MONITORED_ATTRIBUTES = {
34
- "aws_instance": {"instance_type", "ami", "vpc_security_group_ids"},
34
+ "aws_instance": {"instance_type", "ami"},
35
35
  "aws_security_group": {"ingress", "egress", "description"},
36
36
  "aws_s3_bucket": {"bucket"},
37
- "aws_iam_role": set(),
38
37
  "aws_db_instance": {"instance_class", "engine", "allocated_storage"},
39
- "aws_lambda_function": {"runtime", "handler", "memory_size", "timeout"}
38
+ "aws_lambda_function": {"runtime", "handler"},
39
+ "aws_iam_role": {"attached_policies"}
40
40
  }
41
41
 
42
42
  MONITORED_RESOURCES = list(MONITORED_ATTRIBUTES.keys())
@@ -0,0 +1,224 @@
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
+
54
+ # 1. INGRESS RULES
55
+ if "ingress" in diff_data:
56
+ expected_ingress = diff_data["ingress"].get("terraform", [])
57
+ live_ingress = diff_data["ingress"].get("live", [])
58
+
59
+ print(f"Checking Security Group {sg_id} for Ingress drift...")
60
+ for live_rule in live_ingress:
61
+ if live_rule not in expected_ingress:
62
+ if confirm_action(f"Revoke unauthorized Inbound rule (Port {live_rule.get('from_port')}) in {sg_id}", env, False):
63
+ try:
64
+ ec2.revoke_security_group_ingress(GroupId=sg_id, IpPermissions=[{
65
+ 'IpProtocol': live_rule['protocol'], 'FromPort': live_rule['from_port'], 'ToPort': live_rule['to_port'],
66
+ 'IpRanges': [{'CidrIp': c} for c in live_rule.get('cidr_blocks', []) if c]
67
+ }])
68
+ print(f"✅ [REMEDIATED] Removed unauthorized Inbound Rule from {sg_id}")
69
+ except Exception as e:
70
+ print(f"❌ Failed to revoke Inbound rule: {e}")
71
+
72
+ for exp_rule in expected_ingress:
73
+ if exp_rule not in live_ingress:
74
+ if confirm_action(f"Restore missing IaC Inbound rule (Port {exp_rule.get('from_port')}) in {sg_id}", env, False):
75
+ try:
76
+ ec2.authorize_security_group_ingress(GroupId=sg_id, IpPermissions=[{
77
+ 'IpProtocol': exp_rule['protocol'], 'FromPort': exp_rule['from_port'], 'ToPort': exp_rule['to_port'],
78
+ 'IpRanges': [{'CidrIp': c} for c in exp_rule.get('cidr_blocks', []) if c]
79
+ }])
80
+ print(f"✅ [REMEDIATED] Restored missing Inbound Rule to {sg_id}")
81
+ except Exception as e:
82
+ print(f"❌ Failed to restore Inbound rule: {e}")
83
+
84
+ # 2. EGRESS RULES
85
+ if "egress" in diff_data:
86
+ expected_egress = diff_data["egress"].get("terraform", [])
87
+ live_egress = diff_data["egress"].get("live", [])
88
+
89
+ print(f"Checking Security Group {sg_id} for Egress drift...")
90
+ for live_rule in live_egress:
91
+ if live_rule not in expected_egress:
92
+ if confirm_action(f"Revoke unauthorized Outbound rule (Port {live_rule.get('from_port')}) in {sg_id}", env, False):
93
+ try:
94
+ ec2.revoke_security_group_egress(GroupId=sg_id, IpPermissions=[{
95
+ 'IpProtocol': live_rule['protocol'], 'FromPort': live_rule['from_port'], 'ToPort': live_rule['to_port'],
96
+ 'IpRanges': [{'CidrIp': c} for c in live_rule.get('cidr_blocks', []) if c]
97
+ }])
98
+ print(f"✅ [REMEDIATED] Removed unauthorized Outbound Rule from {sg_id}")
99
+ except Exception as e:
100
+ print(f"❌ Failed to revoke Outbound rule: {e}")
101
+
102
+ for exp_rule in expected_egress:
103
+ if exp_rule not in live_egress:
104
+ if confirm_action(f"Restore missing IaC Outbound rule (Port {exp_rule.get('from_port')}) in {sg_id}", env, False):
105
+ try:
106
+ ec2.authorize_security_group_egress(GroupId=sg_id, IpPermissions=[{
107
+ 'IpProtocol': exp_rule['protocol'], 'FromPort': exp_rule['from_port'], 'ToPort': exp_rule['to_port'],
108
+ 'IpRanges': [{'CidrIp': c} for c in exp_rule.get('cidr_blocks', []) if c]
109
+ }])
110
+ print(f"✅ [REMEDIATED] Restored missing Outbound Rule to {sg_id}")
111
+ except Exception as e:
112
+ print(f"❌ Failed to restore Outbound rule: {e}")
113
+
114
+ # 3. DESCRIPTION
115
+ if "description" in diff_data:
116
+ print(f"⚠️ Description drift detected for {sg_id}. SG descriptions cannot be updated dynamically. Please recreate via Terraform.")
117
+
118
+ def remediate_s3_bucket(bucket_name: str, diff_data: dict, env: str):
119
+ s3 = boto3.client('s3')
120
+
121
+ if "tags" in diff_data:
122
+ expected_tags = diff_data["tags"].get("terraform", {})
123
+ if confirm_action(f"Restore IaC tags for S3 Bucket '{bucket_name}'", env, False):
124
+ try:
125
+ tag_set = [{'Key': k, 'Value': v} for k, v in expected_tags.items()]
126
+ s3.put_bucket_tagging(Bucket=bucket_name, Tagging={'TagSet': tag_set})
127
+ print(f"✅ [REMEDIATED] Successfully restored tags for {bucket_name}")
128
+ except Exception as e:
129
+ print(f"❌ Failed to restore S3 tags: {e}")
130
+
131
+ if "bucket" in diff_data:
132
+ print(f"⚠️ Bucket name drift detected for {bucket_name}. S3 buckets cannot be renamed. Please recreate via Terraform.")
133
+
134
+ def remediate_rds_instance(region: str, db_id: str, diff_data: dict, env: str):
135
+ rds = boto3.client('rds', region_name=region)
136
+ updates = {}
137
+
138
+ if "instance_class" in diff_data:
139
+ updates['DBInstanceClass'] = diff_data["instance_class"]["terraform"]
140
+ if "allocated_storage" in diff_data:
141
+ updates['AllocatedStorage'] = diff_data["allocated_storage"]["terraform"]
142
+
143
+ if updates:
144
+ if confirm_action(f"Modify RDS {db_id} with new configs: {updates}", env, True):
145
+ try:
146
+ updates['ApplyImmediately'] = True
147
+ rds.modify_db_instance(DBInstanceIdentifier=db_id, **updates)
148
+ print(f"✅ [REMEDIATED] Initiated RDS modification for {db_id}. This may take several minutes.")
149
+ except Exception as e:
150
+ print(f"❌ Failed to remediate RDS {db_id}: {e}")
151
+
152
+ def remediate_lambda_function(region: str, func_name: str, diff_data: dict, env: str):
153
+ lam = boto3.client('lambda', region_name=region)
154
+ updates = {}
155
+
156
+ if "runtime" in diff_data: updates['Runtime'] = diff_data["runtime"]["terraform"]
157
+ if "handler" in diff_data: updates['Handler'] = diff_data["handler"]["terraform"]
158
+ if "memory_size" in diff_data: updates['MemorySize'] = diff_data["memory_size"]["terraform"]
159
+ if "timeout" in diff_data: updates['Timeout'] = diff_data["timeout"]["terraform"]
160
+
161
+ if updates:
162
+ if confirm_action(f"Modify Lambda {func_name} with {updates}", env, False):
163
+ try:
164
+ lam.update_function_configuration(FunctionName=func_name, **updates)
165
+ print(f"✅ [REMEDIATED] Successfully modified Lambda {func_name}")
166
+ except Exception as e:
167
+ print(f"❌ Failed to remediate Lambda {func_name}: {e}")
168
+
169
+ def remediate_iam_role(role_name: str, diff_data: dict, env: str):
170
+ iam = boto3.client('iam')
171
+ expected_policies = diff_data.get("attached_policies", {}).get("terraform", [])
172
+ live_policies = diff_data.get("attached_policies", {}).get("live", [])
173
+
174
+ print(f"Checking IAM Role {role_name} for unauthorized policies...")
175
+ for live_policy in live_policies:
176
+ if live_policy not in expected_policies:
177
+ if confirm_action(f"Detach unauthorized policy '{live_policy}' from Role '{role_name}'", env, False):
178
+ try:
179
+ iam.detach_role_policy(RoleName=role_name, PolicyArn=live_policy)
180
+ print(f"✅ [REMEDIATED] Successfully detached {live_policy} from {role_name}")
181
+ except Exception as e:
182
+ print(f"❌ Failed to detach policy from {role_name}: {e}")
183
+
184
+ for exp_policy in expected_policies:
185
+ if exp_policy not in live_policies:
186
+ if confirm_action(f"Attach missing IaC policy '{exp_policy}' to Role '{role_name}'", env, False):
187
+ try:
188
+ iam.attach_role_policy(RoleName=role_name, PolicyArn=exp_policy)
189
+ print(f"✅ [REMEDIATED] Successfully attached {exp_policy} to {role_name}")
190
+ except Exception as e:
191
+ print(f"❌ Failed to attach policy to {role_name}: {e}")
192
+
193
+ def process_remediation(drift_results: list):
194
+ region = os.environ.get("AWS_DEFAULT_REGION", "ap-south-1")
195
+ for result in drift_results:
196
+ attrs = result.live_attributes or result.tf_attributes or {}
197
+ tags = attrs.get('tags', {})
198
+ env = get_environment_tag(tags)
199
+
200
+ if result.drift_type.value == "MODIFIED":
201
+ print(f"\n--- Drift Detected: {result.resource_type} ({result.resource_id}) ---")
202
+
203
+ if result.resource_type == "aws_instance" and "instance_type" in result.diff:
204
+ expected_type = result.diff["instance_type"]["terraform"]
205
+ remediate_ec2_instance_type(region, result.resource_id, expected_type, env)
206
+
207
+ elif result.resource_type == "aws_security_group":
208
+ remediate_security_group(region, result.resource_id, result.diff, env)
209
+
210
+ elif result.resource_type == "aws_s3_bucket":
211
+ remediate_s3_bucket(result.resource_id, result.diff, env)
212
+
213
+ elif result.resource_type == "aws_db_instance":
214
+ remediate_rds_instance(region, result.resource_id, result.diff, env)
215
+
216
+ elif result.resource_type == "aws_lambda_function":
217
+ remediate_lambda_function(region, result.resource_id, result.diff, env)
218
+
219
+ elif result.resource_type == "aws_iam_role":
220
+ remediate_iam_role(result.resource_id, result.diff, env)
221
+
222
+ elif result.drift_type.value in ["MISSING", "UNMANAGED"]:
223
+ print(f"\n--- {result.drift_type.value} Resource Detected: {result.resource_type} ({result.resource_id}) ---")
224
+ print(f"⚠️ Auto-remediation for {result.drift_type.value} resources is not executed directly. Please review the AI Analysis in the scan report for the exact `terraform import` or creation commands required to fix this.")
@@ -0,0 +1,67 @@
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
+ continue
39
+
40
+ resource_id = attrs.get("id")
41
+
42
+ tags = attrs.get("tags", {})
43
+ if tags and tags.get("Name"):
44
+ name = tags.get("Name")
45
+ elif r_type == "aws_lambda_function":
46
+ name = attrs.get("function_name", "Unknown")
47
+ elif r_type == "aws_db_instance":
48
+ name = attrs.get("identifier", "Unknown")
49
+ elif r_type == "aws_iam_role":
50
+ name = attrs.get("name", "Unknown")
51
+ if "attached_policies" not in attrs:
52
+ attrs["attached_policies"] = []
53
+ else:
54
+ name = "Unknown"
55
+
56
+ if resource_id:
57
+ if resource_id in resources and r_type == "aws_iam_role":
58
+ existing_policies = resources[resource_id]["attributes"].get("attached_policies", [])
59
+ attrs["attached_policies"] = list(set(attrs.get("attached_policies", []) + existing_policies))
60
+
61
+ resources[resource_id] = {
62
+ "type": r_type,
63
+ "name": name,
64
+ "attributes": attrs
65
+ }
66
+
67
+ return resources
@@ -1,23 +1,13 @@
1
1
  import typer
2
2
  import os
3
- import sys
3
+ import boto3
4
4
  from datetime import datetime
5
+ from dotenv import load_dotenv
5
6
 
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
7
+ load_dotenv()
8
+
9
+ from drift_engine.core import detect_drift, get_severity
10
+ from drift_engine.aws_client import get_resource_cost
21
11
  from drift_engine.explain import get_drift_explanation
22
12
  from drift_engine.models import DriftType
23
13
  from drift_engine.notifications import process_alerts
@@ -31,15 +21,21 @@ SEVERITY_RANK = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
31
21
  @app.command()
32
22
  def scan(
33
23
  state: str = typer.Option("terraform/terraform.tfstate"),
34
- region: str = typer.Option("ap-south-1"),
24
+ region: str = typer.Option(None),
35
25
  fail_on: str = typer.Option(None)
36
26
  ):
37
- typer.echo("Scanning AWS Infrastructure...\n")
27
+ actual_region = region or os.environ.get("AWS_DEFAULT_REGION") or boto3.Session().region_name
28
+
29
+ if not actual_region:
30
+ typer.secho("No AWS region found. Pass --region or set AWS_DEFAULT_REGION.", fg=typer.colors.RED)
31
+ raise typer.Exit(1)
32
+
33
+ typer.echo(f"Scanning AWS Infrastructure in {actual_region}...\n")
38
34
  os.environ["TF_STATE_PATH"] = state
39
- os.environ["AWS_DEFAULT_REGION"] = region
35
+ os.environ["AWS_DEFAULT_REGION"] = actual_region
40
36
 
41
37
  try:
42
- results, total_scanned = detect_drift(state, region)
38
+ results, total_scanned = detect_drift(state, actual_region)
43
39
  current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S IST")
44
40
  total_drift = len(results)
45
41
 
@@ -77,16 +73,13 @@ def scan(
77
73
  inst_type = r.live_attributes.get("instance_type", "unknown")
78
74
  typer.echo(f" Type: {inst_type} (created manually in console)")
79
75
  typer.echo(f" Severity: {severity} | Cost: +Rs.{cost}/month (untracked)")
80
-
81
76
  ai_text = get_drift_explanation(r.resource_type, r.resource_id, r.live_attributes, r.drift_type.value)
82
-
83
77
  elif r.diff:
84
78
  typer.echo(f" Severity: {severity}")
85
79
  for attr, vals in r.diff.items():
86
80
  typer.echo(f" Attribute: {attr}")
87
81
  typer.echo(f" Terraform: {vals['terraform']}")
88
82
  typer.echo(f" Live AWS: {vals['live']}")
89
-
90
83
  ai_text = get_drift_explanation(r.resource_type, r.resource_id, r.diff, r.drift_type.value)
91
84
  else:
92
85
  typer.echo(f" Severity: {severity}")
@@ -98,8 +91,6 @@ def scan(
98
91
  else:
99
92
  typer.echo("\n")
100
93
 
101
- process_drift_results(r.resource_id, r.drift_type.value, ai_text)
102
-
103
94
  typer.echo(f"Total drift found: {total_drift} resources | CRITICAL: {crit_count} HIGH: {high_count}\n")
104
95
 
105
96
  process_alerts(results)
@@ -122,19 +113,46 @@ def scan(
122
113
  raise typer.Exit(code=1)
123
114
 
124
115
  @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")
116
+ def explain(
117
+ resource_id: str,
118
+ state: str = typer.Option("terraform/terraform.tfstate"),
119
+ region: str = typer.Option(None)
120
+ ):
121
+ actual_region = region or os.environ.get("AWS_DEFAULT_REGION") or boto3.Session().region_name
122
+
123
+ if not actual_region:
124
+ typer.secho("No AWS region found. Pass --region or set AWS_DEFAULT_REGION.", fg=typer.colors.RED)
125
+ raise typer.Exit(1)
126
+
127
+ typer.echo(f"Fetching current drift state for {resource_id}...")
128
+ results, _ = detect_drift(state, actual_region)
129
+ match = [r for r in results if r.resource_id == resource_id]
130
+
131
+ if not match:
132
+ typer.secho(f"No current drift found for {resource_id}.", fg=typer.colors.YELLOW)
133
+ raise typer.Exit(code=1)
134
+
135
+ r = match[0]
136
+ diff_data = r.diff if r.diff else r.live_attributes
137
+
138
+ typer.echo(f"Generating AI explanation...")
139
+ ai_text = get_drift_explanation(r.resource_type, r.resource_id, diff_data, r.drift_type.value)
128
140
  typer.echo(f"\nAI Analysis:\n{ai_text}")
129
141
 
130
142
  @app.command()
131
143
  def remediate(
132
144
  resource_id: str,
133
145
  state: str = typer.Option("terraform/terraform.tfstate"),
134
- region: str = typer.Option("ap-south-1"),
146
+ region: str = typer.Option(None),
135
147
  dry_run: bool = typer.Option(True, "--dry-run/--apply"),
136
148
  ):
137
- results, _ = detect_drift(state, region)
149
+ actual_region = region or os.environ.get("AWS_DEFAULT_REGION") or boto3.Session().region_name
150
+
151
+ if not actual_region:
152
+ typer.secho("No AWS region found. Pass --region or set AWS_DEFAULT_REGION.", fg=typer.colors.RED)
153
+ raise typer.Exit(1)
154
+
155
+ results, _ = detect_drift(state, actual_region)
138
156
  match = [r for r in results if r.resource_id == resource_id]
139
157
 
140
158
  if not match:
@@ -1,7 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driftwatch-cli
3
- Version: 3.0.0
3
+ Version: 3.0.1
4
4
  Summary: CLI tool that detects Terraform infrastructure drift against live AWS, explains it with AI, and guides remediation.
5
+ Author: Nitin Gupta
6
+ Project-URL: Homepage, https://github.com/hastagnitin/driftwatch
7
+ Project-URL: Repository, https://github.com/hastagnitin/driftwatch
5
8
  Requires-Python: >=3.10
6
9
  License-File: LICENSE
7
10
  Requires-Dist: boto3
@@ -10,4 +13,5 @@ Requires-Dist: groq
10
13
  Requires-Dist: python-telegram-bot
11
14
  Requires-Dist: psycopg2-binary
12
15
  Requires-Dist: requests
16
+ Requires-Dist: python-dotenv
13
17
  Dynamic: license-file
@@ -4,3 +4,4 @@ groq
4
4
  python-telegram-bot
5
5
  psycopg2-binary
6
6
  requests
7
+ python-dotenv
@@ -4,8 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "driftwatch-cli"
7
- version = "3.0.0"
7
+ version = "3.0.1"
8
8
  description = "CLI tool that detects Terraform infrastructure drift against live AWS, explains it with AI, and guides remediation."
9
+ authors = [
10
+ {name = "Nitin Gupta"}
11
+ ]
9
12
  requires-python = ">=3.10"
10
13
  dependencies = [
11
14
  "boto3",
@@ -13,9 +16,14 @@ dependencies = [
13
16
  "groq",
14
17
  "python-telegram-bot",
15
18
  "psycopg2-binary",
16
- "requests"
19
+ "requests",
20
+ "python-dotenv"
17
21
  ]
18
22
 
23
+ [project.urls]
24
+ Homepage = "https://github.com/hastagnitin/driftwatch"
25
+ Repository = "https://github.com/hastagnitin/driftwatch"
26
+
19
27
  [project.scripts]
20
28
  driftwatch = "driftwatch.cli:app"
21
29
 
@@ -1,193 +0,0 @@
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}")
@@ -1,40 +0,0 @@
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
File without changes