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