LDAP-Monitor 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,50 @@
1
+ # This file is an original work developed by Joshua Rogers<https://joshua.hu/>.
2
+ # Licensed under the GPL3.0 License.
3
+
4
+ import json
5
+ import os
6
+
7
+
8
+ def _parse_bool(val):
9
+ return val.lower() in ('true', '1', 'yes')
10
+
11
+
12
+ def _parse_list(val):
13
+ if not val or not val.strip():
14
+ return []
15
+ return [item.strip() for item in val.split(',')]
16
+
17
+
18
+ def _parse_json_dict(val):
19
+ if not val or not val.strip():
20
+ return {}
21
+ return json.loads(val)
22
+
23
+
24
+ CONTROL_UUID = os.getenv('LDAP_WATCHDOG_CONTROL_UUID', '')
25
+ CONTROL_USER_ATTRIBUTE = os.getenv('LDAP_WATCHDOG_CONTROL_USER_ATTRIBUTE', '')
26
+
27
+ LDAP_SERVER = os.getenv('LDAP_WATCHDOG_SERVER', '')
28
+ LDAP_USERNAME = os.getenv('LDAP_WATCHDOG_USERNAME', '')
29
+ LDAP_PASSWORD = os.getenv('LDAP_WATCHDOG_PASSWORD', '')
30
+ LDAP_USE_SSL = _parse_bool(os.getenv('LDAP_WATCHDOG_USE_SSL', 'true'))
31
+ BASE_DN = os.getenv('LDAP_WATCHDOG_BASE_DN', '')
32
+
33
+ DISABLE_COLOR_OUTPUT = _parse_bool(os.getenv('LDAP_WATCHDOG_DISABLE_COLOR_OUTPUT', 'false'))
34
+
35
+ SEARCH_FILTER = os.getenv('LDAP_WATCHDOG_SEARCH_FILTER', '(&(|(objectClass=inetOrgPerson)(objectClass=groupOfNames)))')
36
+ SEARCH_ATTRIBUTE = _parse_list(os.getenv('LDAP_WATCHDOG_SEARCH_ATTRIBUTE', '*,+'))
37
+
38
+ REFRESH_RATE = int(os.getenv('LDAP_WATCHDOG_REFRESH_RATE', '60'))
39
+
40
+ SLACK_BULLETPOINT = os.getenv('LDAP_WATCHDOG_SLACK_BULLETPOINT', ' \u2022 ')
41
+
42
+ IGNORED_UUIDS = _parse_list(os.getenv('LDAP_WATCHDOG_IGNORED_UUIDS', ''))
43
+ IGNORED_ATTRIBUTES = _parse_list(os.getenv('LDAP_WATCHDOG_IGNORED_ATTRIBUTES', ''))
44
+
45
+ CONDITIONAL_IGNORED_ATTRIBUTES = _parse_json_dict(os.getenv('LDAP_WATCHDOG_CONDITIONAL_IGNORED_ATTRIBUTES', '{}'))
46
+
47
+ SLACK_WEBHOOK = os.getenv('SLACK_WEBHOOK_URL')
48
+
49
+ if SLACK_WEBHOOK and len(SLACK_WEBHOOK) > 0:
50
+ import requests # noqa: F401
@@ -0,0 +1,343 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # This file is an original work developed by Joshua Rogers<https://joshua.hu/>.
4
+ # Licensed under the GPL3.0 License. You should have received a copy of the GNU General Public License along with LDAP Watchdog. If not, see <https://www.gnu.org/licenses/>.
5
+
6
+ import json
7
+ import sys
8
+ from datetime import datetime
9
+ import time
10
+
11
+ from ldap3 import Server, Connection, ALL, SUBTREE
12
+ from ldap3.core.exceptions import LDAPSocketOpenError
13
+
14
+ from ldap_watchdog import config
15
+
16
+ if config.SLACK_WEBHOOK and len(config.SLACK_WEBHOOK) > 0:
17
+ import requests
18
+
19
+
20
+ def col(op_type):
21
+ """
22
+ Returns ANSI color codes for different LDAP operation types.
23
+
24
+ Parameters:
25
+ - op_type (str): LDAP operation type ('add', 'delete', 'modify').
26
+
27
+ Returns:
28
+ - str: ANSI color code.
29
+ """
30
+ if config.DISABLE_COLOR_OUTPUT:
31
+ return ''
32
+ return {'add': "\033[1m\033[32m", 'delete': "\033[3m\033[31m", 'modify': "\033[33m"}[op_type]
33
+
34
+
35
+ def retrieve_ldap():
36
+ """
37
+ Connects to the LDAP server and retrieves LDAP entries.
38
+
39
+ Returns:
40
+ - dict: Dictionary containing LDAP entries.
41
+ """
42
+ entries = {}
43
+ server = Server(config.LDAP_SERVER, use_ssl=config.LDAP_USE_SSL, get_info=ALL)
44
+ if config.LDAP_USERNAME and config.LDAP_PASSWORD:
45
+ conn = Connection(server, user=config.LDAP_USERNAME, password=config.LDAP_PASSWORD)
46
+ if not conn.bind():
47
+ print('Error in bind:', conn.result, file=sys.stderr)
48
+ return entries
49
+ else:
50
+ conn = Connection(server)
51
+ if not conn.bind():
52
+ print('Anonymous bind failed:', conn.result, file=sys.stderr)
53
+ return entries
54
+
55
+ conn.search(search_base=config.BASE_DN, search_filter=config.SEARCH_FILTER, search_scope=SUBTREE, attributes=config.SEARCH_ATTRIBUTE)
56
+
57
+ for entry in conn.entries:
58
+ entry = json.loads(entry.entry_to_json())
59
+ entry_dict = entry['attributes']
60
+ for attr_name, attr_value in entry_dict.items():
61
+ attr_value = attr_value[0]
62
+ # Some entries may be encoded using base64 and provided by a dictionary.
63
+ # In that case, replace the dictionary with a string of the encoded data.
64
+ if isinstance(attr_value, dict) and len(attr_value) == 2 and 'encoded' in attr_value and 'encoding' in attr_value and attr_value['encoding'] == 'base64':
65
+ decoded_value = attr_value['encoded']
66
+ entry_dict[attr_name] = decoded_value
67
+
68
+ entry_dict['dn'] = [entry['dn']]
69
+ entries[entry_dict['entryUUID'][0]] = entry_dict
70
+
71
+ return entries
72
+
73
+ def generate_message(dn_uuid, op_type, changes):
74
+ """
75
+ Generates formatted messages for Slack and console output based on LDAP changes.
76
+
77
+ Parameters:
78
+ - dn_uuid (str): LDAP entry's UUID.
79
+ - op_type (str): LDAP operation type ('add', 'delete', 'modify').
80
+ - changes (dict): Dictionary containing LDAP attribute changes.
81
+
82
+ Returns:
83
+ - tuple: (str, str) Tuple containing Slack and console-formatted messages.
84
+ """
85
+ now = datetime.now()
86
+ timestamp = now.strftime('%d/%m/%Y %H:%M:%S')
87
+
88
+ rst_col = "\033[0m"
89
+ stt_col = "\033[1m\033[35m"
90
+ if config.DISABLE_COLOR_OUTPUT:
91
+ rst_col = ""
92
+ stt_col = ""
93
+
94
+ bl = f"{config.SLACK_BULLETPOINT}{op_type}"
95
+
96
+ slack_msg = f"*[{timestamp}] {op_type} {dn_uuid}*\n"
97
+ print_msg = f"[{stt_col}{timestamp}{rst_col}] {op_type}{col(op_type)} {dn_uuid}{rst_col}\n"
98
+
99
+ if op_type == 'modify':
100
+ for additions in changes['additions']:
101
+ for key, vals in additions.items():
102
+ for val in vals:
103
+ slack_msg += f"{config.SLACK_BULLETPOINT}add *'{key}'* to *'{val}'*\n"
104
+ print_msg += f"{config.SLACK_BULLETPOINT}add '{col('modify')}{key}{rst_col}' to '{col('add')}{val}{rst_col}'\n"
105
+ for removals in changes['removals']:
106
+ for key, vals in removals.items():
107
+ for val in vals:
108
+ slack_msg += f"{config.SLACK_BULLETPOINT}delete *'{key}'* was _'{val}'_\n"
109
+ print_msg += f"{config.SLACK_BULLETPOINT}delete '{col('modify')}{key}{rst_col}' was '{col('delete')}{val}{rst_col}'\n"
110
+ for modifications in changes['modifications']:
111
+ for key, val in modifications.items():
112
+ slack_msg += f"{config.SLACK_BULLETPOINT}modify *'{key}'* to *'{val[1]}'* was _'{val[0]}'_\n"
113
+ print_msg += f"{config.SLACK_BULLETPOINT}modify '{col('modify')}{key}{rst_col}' to '{col('add')}{val[1]}{rst_col}' was '{col('delete')}{val[0]}{rst_col}'\n"
114
+ elif op_type == 'delete':
115
+ for key, vals in changes.items():
116
+ for val in vals:
117
+ slack_msg += f"{bl} *'{key}'* was _'{val}'_\n"
118
+ print_msg += f"{bl} '{col('modify')}{key}{rst_col}' was '{col('delete')}{val}{rst_col}'\n"
119
+ elif op_type == 'add':
120
+ for key, vals in changes.items():
121
+ for val in vals:
122
+ slack_msg += f"{bl} *'{key}'* to *'{val}'*\n"
123
+ print_msg += f"{bl} '{col('modify')}{key}{rst_col}' to '{col('add')}{val}{rst_col}'\n"
124
+
125
+ return slack_msg, print_msg
126
+
127
+
128
+ def announce(dn_uuid, op_type, changes):
129
+ """
130
+ Sends notification messages to Slack and prints to the console.
131
+
132
+ Parameters:
133
+ - dn_uuid (str): LDAP entry's UUID.
134
+ - op_type (str): LDAP operation type ('add', 'delete', 'modify').
135
+ - changes (dict): Dictionary containing LDAP attribute changes.
136
+
137
+ Returns:
138
+ - None
139
+ """
140
+ slack_msg, print_msg = generate_message(dn_uuid, op_type, changes)
141
+ send_to_slack(slack_msg)
142
+ print(print_msg)
143
+
144
+
145
+ def truncate_slack_message(message):
146
+ """
147
+ Truncates long Slack messages to fit within the character limit.
148
+
149
+ Parameters:
150
+ - message (str): Slack message.
151
+
152
+ Returns:
153
+ - str: Truncated Slack message.
154
+ """
155
+ while len(message) > 4000:
156
+ longest_word = max(message.split(), key=len)
157
+ message = message.replace(longest_word, "[...truncated...]")
158
+
159
+ return message
160
+
161
+
162
+ def send_to_slack(message):
163
+ """
164
+ Sends a formatted message to Slack.
165
+
166
+ Parameters:
167
+ - message (str): Formatted message.
168
+
169
+ Returns:
170
+ - None
171
+ """
172
+ if config.SLACK_WEBHOOK is None or len(config.SLACK_WEBHOOK) == 0:
173
+ return
174
+
175
+ message = truncate_slack_message(message)
176
+
177
+ headers = {'Content-type': 'application/json'}
178
+ data = {'text': message}
179
+ requests.post(config.SLACK_WEBHOOK, headers=headers, data=json.dumps(data))
180
+
181
+
182
+ def check_control_user(old_entries, new_entries):
183
+ """
184
+ Checks if the control user's LDAP entry has changed.
185
+
186
+ Parameters:
187
+ - old_entries (dict): Dictionary containing old LDAP entries.
188
+ - new_entries (dict): Dictionary containing new LDAP entries.
189
+
190
+ Returns:
191
+ - bool: True if the control user's entry has changed, False otherwise.
192
+ """
193
+ control_user_found = False
194
+ if config.CONTROL_UUID not in new_entries or config.CONTROL_UUID not in old_entries:
195
+ return control_user_found
196
+
197
+ new_entry = new_entries[config.CONTROL_UUID]
198
+ old_entry = old_entries[config.CONTROL_UUID]
199
+
200
+ if config.CONTROL_USER_ATTRIBUTE and len(config.CONTROL_USER_ATTRIBUTE) > 0:
201
+ # If using CONTROL_USER_ATTRIBUTE, only compare that attribute as long as it exists in both new_entry and old_entry.
202
+ if config.CONTROL_USER_ATTRIBUTE in new_entry and config.CONTROL_USER_ATTRIBUTE in old_entry and new_entry[config.CONTROL_USER_ATTRIBUTE] != old_entry[config.CONTROL_USER_ATTRIBUTE]:
203
+ control_user_found = True
204
+ else:
205
+ # Otherwise, check that at least one attribute has changed, regardless of what it is.
206
+ for new_attribute in new_entry.keys():
207
+ if new_attribute in old_entry and old_entry[new_attribute] != new_entry[new_attribute]:
208
+ control_user_found = True
209
+ break
210
+
211
+ return control_user_found
212
+
213
+
214
+ def compare_ldap_entries(old_entries, new_entries):
215
+ """
216
+ Compares old and new LDAP entries and announces modifications.
217
+
218
+ Parameters:
219
+ - old_entries (dict): Dictionary containing old LDAP entries.
220
+ - new_entries (dict): Dictionary containing new LDAP entries.
221
+
222
+ Returns:
223
+ - None
224
+ """
225
+ if len(config.CONTROL_UUID) > 0 and not check_control_user(old_entries, new_entries):
226
+ print('Could not confirm the control user change.', file=sys.stderr)
227
+ return
228
+
229
+ # XXX: The next four lines of code do not consider ignored UUIDs or ignored attributes.
230
+ for uuid in old_entries.keys() - new_entries.keys():
231
+ # Any entries that are in old_entries but not new_entries are deletions.
232
+ announce(f"{old_entries[uuid]['dn'][0]} ({old_entries[uuid]['entryUUID'][0]})", "delete", old_entries[uuid])
233
+
234
+ for uuid in new_entries.keys() - old_entries.keys():
235
+ # Any entries that are in new_entries but not old_entries are additions.
236
+ announce(f"{new_entries[uuid]['dn'][0]} ({new_entries[uuid]['entryUUID'][0]})", "add", new_entries[uuid])
237
+
238
+ for uuid in old_entries.keys() & new_entries.keys():
239
+ if uuid in config.IGNORED_UUIDS:
240
+ continue # TODO: print that it was skipped?
241
+ if old_entries[uuid] != new_entries[uuid]:
242
+ # For changes of a user, there are three types of operations to define: additions, removals, and modifications.
243
+ changes = {}
244
+ # XXX: Could these be dictionaries instead?
245
+ changes.setdefault("additions", []) # A list of addition of values to attributes.
246
+ changes.setdefault("modifications", []) # A list of changes of a single value for an attribute.
247
+ changes.setdefault("removals", []) # A list of removal of values from an atttribute.
248
+ for key in old_entries[uuid].keys() | new_entries[uuid].keys():
249
+ # Compare each key (attribute) in the old and new entries
250
+ old_value = old_entries[uuid].get(key)
251
+ new_value = new_entries[uuid].get(key)
252
+ # If they are not the same, we have some type of change.
253
+ if old_value != new_value:
254
+ if old_value is None:
255
+ # If the key is not found in old_entries, it's an addition.
256
+ changes["additions"].append({key: new_value})
257
+ elif new_value is None:
258
+ # If the key is not found in new_entries, it's a removal.
259
+ changes["removals"].append({key: old_value})
260
+ else:
261
+ # If the key is in both old_entries and new_entries but the values are not the same, then may be a modification.
262
+ # There is no way to truly determine whether an attribute's value(s) have been changed, or removed and then a new value added.
263
+ # Therefore, we define a modification as the change of an attribute that has only a single value.
264
+ if len(old_value) == len(new_value) == 1:
265
+ changes["modifications"].append({key: (old_value[0], new_value[0])})
266
+ else:
267
+ # If there is either zero or more than one value for an attribute, then the difference beteen the old values and the new values indicate an addition or removal (or a value; not an entry).
268
+ # That is to say: this is the addition or removal of values for an attribute which does not have exactly one old value and exactly one new value.
269
+ # Therefore, if a new value (or values) is present for an attribute, it is also an addition.
270
+ added = set(new_value) - set(old_value)
271
+ # And if the value (or values) is only in the old data, it is a removal.
272
+ removed = set(old_value) - set(new_value)
273
+ if added:
274
+ changes["additions"].append({key: added})
275
+ if removed:
276
+ changes["removals"].append({key: removed})
277
+
278
+ # It is worth remembering what "changes" really is.
279
+ #
280
+ # changes["modifications"] is a set of dictionaries. Each dictionary's key is an attribute name, and the value is a tuple of (old_ldap_value, new_ldap_value).
281
+ # changes["modifications"] =
282
+ # [
283
+ # { attr_name: (old_val, new_val) },
284
+ # { attr_name2: (old_val2, new_val2) },
285
+ # ]
286
+ #
287
+ # changes["additions"] and changes["removals"] are each a set of dictionaries. Each dictionary's key is an attribute name, and the value is a set of the values for which we wish to ignore.
288
+ # changes["additions"] =
289
+ # [
290
+ # { attr_name: [val1, val2] },
291
+ # { attr_name2: [val1, val2] },
292
+ # ]
293
+
294
+ for change_type in ["additions", "modifications", "removals"]:
295
+ for ignored_attr_name in config.IGNORED_ATTRIBUTES:
296
+ for change in changes[change_type][:]: # Using a shallow copy of each dictionary.
297
+ # For each change type, check whether any of the changed attribute names should be ignored.
298
+ if ignored_attr_name in change:
299
+ print(f"Ignoring {old_entries[uuid]['dn'][0]} ({old_entries[uuid]['entryUUID'][0]}) {change}", file=sys.stderr)
300
+ changes[change_type].remove(change)
301
+
302
+ for change_type in ["additions", "modifications", "removals"]:
303
+ for ignored_attr_name, ignored_attr_list in config.CONDITIONAL_IGNORED_ATTRIBUTES.items():
304
+ for change in changes[change_type][:]: # 'change' is each dictionary in changes[change_type].
305
+ if ignored_attr_name in change: # Check if the ignored attribute is in the dictionary
306
+ if change_type == "modifications": # For modifications, we ignore the change if either the new or old value of the ignored attribute is the ignored value.
307
+ old_attr_value = change[ignored_attr_name][0] # old value
308
+ new_attr_value = change[ignored_attr_name][1] # new value
309
+ if old_attr_value in ignored_attr_list or new_attr_value in ignored_attr_list:
310
+ print(f"Ignoring {change_type} of {old_entries[uuid]['dn'][0]} ({old_entries[uuid]['entryUUID'][0]}) {ignored_attr_name}: from {old_attr_value} to {new_attr_value}", file=sys.stderr)
311
+ changes[change_type].remove(change) # Remove the whole dictionary from changes["modifications"].
312
+ else:
313
+ for added_or_removed_val in change[ignored_attr_name][:]: # val1, val2, ...
314
+ if added_or_removed_val in ignored_attr_list: # Check whether the value should be ignored.
315
+ print(f"Ignoring {change_type} of {old_entries[uuid]['dn'][0]} ({old_entries[uuid]['entryUUID'][0]}) {ignored_attr_name}: {added_or_removed_val}", file=sys.stderr)
316
+ change[ignored_attr_name].remove(added_or_removed_val) # Remove the ignored value from the set of added/removed attributes for attribute ignored_attr_name.
317
+ if len(change[ignored_attr_name]) == 0: # If the added/removed attribute set for ignored_attr_name is in now empty ( {attr_name: []} ) then delete it.
318
+ changes[change_type].remove(change)
319
+
320
+ for change_type in ["additions", "modifications", "removals"]:
321
+ if len(changes[change_type]) > 0:
322
+ announce(f"{old_entries[uuid]['dn'][0]} ({old_entries[uuid]['entryUUID'][0]})", "modify", changes)
323
+ break
324
+
325
+
326
+ def main():
327
+ new_entries = retrieve_ldap()
328
+ while True:
329
+ time.sleep(config.REFRESH_RATE)
330
+
331
+ try:
332
+ retrieved_entries = retrieve_ldap()
333
+ except LDAPSocketOpenError as e:
334
+ print(f"LDAP connection error: {e}", file=sys.stderr)
335
+ continue
336
+
337
+ old_entries = new_entries
338
+ new_entries = retrieved_entries
339
+ compare_ldap_entries(old_entries, new_entries)
340
+
341
+
342
+ if __name__ == '__main__':
343
+ main()