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.
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/PKG-INFO +5 -1
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/aws_client.py +56 -43
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/core.py +34 -88
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/database.py +16 -5
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/models.py +3 -3
- driftwatch_cli-3.0.1/drift_engine/remediation.py +224 -0
- driftwatch_cli-3.0.1/drift_engine/tf_parser.py +67 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch/cli.py +48 -30
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/PKG-INFO +5 -1
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/requires.txt +1 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/pyproject.toml +10 -2
- driftwatch_cli-3.0.0/drift_engine/remediation.py +0 -193
- driftwatch_cli-3.0.0/drift_engine/tf_parser.py +0 -40
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/LICENSE +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/__init__.py +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/explain.py +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/drift_engine/notifications.py +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch/__init__.py +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/SOURCES.txt +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/dependency_links.txt +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/entry_points.txt +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/driftwatch_cli.egg-info/top_level.txt +0 -0
- {driftwatch_cli-3.0.0 → driftwatch_cli-3.0.1}/setup.cfg +0 -0
- {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.
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
for
|
|
11
|
-
for
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
from
|
|
4
|
-
from
|
|
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=
|
|
12
|
-
user=
|
|
13
|
-
password=
|
|
14
|
-
host=
|
|
15
|
-
port=
|
|
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"
|
|
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",
|
|
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
|
|
3
|
+
import boto3
|
|
4
4
|
from datetime import datetime
|
|
5
|
+
from dotenv import load_dotenv
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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(
|
|
24
|
+
region: str = typer.Option(None),
|
|
35
25
|
fail_on: str = typer.Option(None)
|
|
36
26
|
):
|
|
37
|
-
|
|
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"] =
|
|
35
|
+
os.environ["AWS_DEFAULT_REGION"] = actual_region
|
|
40
36
|
|
|
41
37
|
try:
|
|
42
|
-
results, total_scanned = detect_drift(state,
|
|
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(
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
146
|
+
region: str = typer.Option(None),
|
|
135
147
|
dry_run: bool = typer.Option(True, "--dry-run/--apply"),
|
|
136
148
|
):
|
|
137
|
-
|
|
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.
|
|
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,8 +4,11 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "driftwatch-cli"
|
|
7
|
-
version = "3.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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|