LDAP-Monitor 1.0.0__tar.gz → 2.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LDAP-Monitor
3
- Version: 1.0.0
3
+ Version: 2.0.0
4
4
  Summary: Monitor LDAP directory changes in real-time
5
5
  Home-page: https://github.com/MegaManSec/LDAP-Monitoring-Watchdog
6
6
  Author: Joshua Rogers
@@ -9,9 +9,6 @@ License: GPL-3.0
9
9
  Project-URL: Blog Post, https://joshua.hu/ldap-watchdog-openldap-python-monitoring-tool-realtime-directory-slack-notifications
10
10
  Project-URL: Author, https://joshua.hu/
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.7
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
12
  Classifier: Programming Language :: Python :: 3.10
16
13
  Classifier: Programming Language :: Python :: 3.11
17
14
  Classifier: Programming Language :: Python :: 3.12
@@ -20,12 +17,13 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
20
17
  Classifier: Operating System :: OS Independent
21
18
  Classifier: Topic :: System :: Monitoring
22
19
  Classifier: Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP
23
- Requires-Python: >=3.7
20
+ Requires-Python: >=3.10
24
21
  Description-Content-Type: text/markdown
25
22
  License-File: LICENSE
26
23
  Requires-Dist: ldap3
24
+ Requires-Dist: pyasn1>=0.6.4
27
25
  Provides-Extra: slack
28
- Requires-Dist: requests; extra == "slack"
26
+ Requires-Dist: requests>=2.33.0; extra == "slack"
29
27
  Dynamic: author
30
28
  Dynamic: author-email
31
29
  Dynamic: classifier
@@ -66,15 +64,15 @@ Previously named LDAP-Stalker (because monitoring changes of an LDAP directory i
66
64
 
67
65
  ## Examples (No Filtering)
68
66
 
69
- Note: in the below examples, entryCSN and modifyTimestamp can be completely ignored by setting `LDAP_WATCHDOG_IGNORED_ATTRIBUTES=entryCSN,modifyTimestamp`.
67
+ Note: in the below examples, changes to entryCSN and modifyTimestamp on existing entries can be ignored by setting `LDAP_WATCHDOG_IGNORED_ATTRIBUTES=entryCSN,modifyTimestamp`.
70
68
 
71
69
  ### Terminal (with color) output:
72
70
 
73
- ![Example of the output from LDAP Watchdog](example.png "LDAP Watchdog")
71
+ ![Example of the output from LDAP Watchdog](https://raw.githubusercontent.com/MegaManSec/LDAP-Monitoring-Watchdog/main/example.png "LDAP Watchdog")
74
72
 
75
73
  ### Slack output:
76
74
 
77
- ![Example of the output from LDAP Watchdog in Slack](example-slack.png "LDAP Watchdog")
75
+ ![Example of the output from LDAP Watchdog in Slack](https://raw.githubusercontent.com/MegaManSec/LDAP-Monitoring-Watchdog/main/example-slack.png "LDAP Watchdog")
78
76
 
79
77
  ## Features
80
78
 
@@ -90,13 +88,13 @@ Note: in the below examples, entryCSN and modifyTimestamp can be completely igno
90
88
 
91
89
  6. **Customizable Output:** Console output provides clear and colored indications of additions, modifications, and deletions for easy visibility.
92
90
 
93
- 7. **Ignored Entries and Attributes:** Users can specify UUIDs and attributes to be ignored during the comparison process.
91
+ 7. **Ignored Entries and Attributes:** Users can specify UUIDs and attributes to ignore when comparing changes to existing entries.
94
92
 
95
93
  8. **Conditional Ignored Attributes:** Conditional filtering allows users to ignore specific attributes based on change type (additions, modifications, deletions).
96
94
 
97
95
 
98
96
  ## Requirements
99
- - Python 3.7+.
97
+ - Python 3.10+.
100
98
  - The `ldap3` package. If using a Slack webhook, the `requests` package is also required.
101
99
 
102
100
  ## Installation
@@ -166,10 +164,10 @@ docker run -d \
166
164
 
167
165
  ### Systemd Installation (Debian-based)
168
166
 
169
- A Debian-based installation script, [install.sh](install.sh), is provided. When run as root, this script:
167
+ A Debian-based installation script, [install.sh](https://github.com/MegaManSec/LDAP-Monitoring-Watchdog/blob/main/install.sh), is provided. When run as root from a clone of this repository, this script:
170
168
 
171
169
  1. Creates (if necessary) a Python virtual environment in `/opt/ldap-watchdog`.
172
- 2. Installs LDAP Watchdog from PyPI into that virtual environment.
170
+ 2. Installs LDAP Watchdog from the clone into that virtual environment, with the dependency versions pinned in `requirements.txt`.
173
171
  3. Creates an environment file at `/etc/ldap-watchdog.env` for configuration.
174
172
  4. Installs and enables a systemd service (`/etc/systemd/system/ldap-watchdog.service`) that runs **ldap-watchdog** in the background.
175
173
  5. Configures logging to `/var/log/ldap-watchdog.log` and `/var/log/ldap-watchdog-error.log`.
@@ -200,17 +198,24 @@ All configuration is done via environment variables. The following variables are
200
198
  | `LDAP_WATCHDOG_USERNAME` | LDAP username for authentication. Leave empty for anonymous bind. | `""` |
201
199
  | `LDAP_WATCHDOG_PASSWORD` | LDAP password for authentication. Leave empty for anonymous bind. | `""` |
202
200
  | `LDAP_WATCHDOG_USE_SSL` | Set to `true` to use SSL, `false` otherwise | `true` |
201
+ | `LDAP_WATCHDOG_CA_CERTS_FILE` | Optional PEM CA bundle for verifying the LDAP server certificate. If unset, system trust roots are used. | Unset |
202
+ | `LDAP_WATCHDOG_TLS_VERIFY` | Set to `false` to skip verifying the LDAP server certificate and host name. The connection is still encrypted but not authenticated. | `true` |
203
203
  | `LDAP_WATCHDOG_SEARCH_FILTER` | LDAP filter for user and group entries | `(&(|(objectClass=inetOrgPerson)(objectClass=groupOfNames)))` |
204
- | `LDAP_WATCHDOG_SEARCH_ATTRIBUTE` | Comma-separated list of attributes to retrieve. [`*,+`](https://joshua.hu/tracking-secret-ldap-login-times-with-modifytimestamp-heuristics) is used by default to include operational attributes. | `*,+` |
204
+ | `LDAP_WATCHDOG_SEARCH_ATTRIBUTE` | Comma-separated list of attributes to retrieve. [`*,+`](https://joshua.hu/tracking-secret-ldap-login-times-with-modifytimestamp-heuristics) is used by default to include operational attributes. `entryUUID` is always requested and must be readable for every entry. | `*,+` |
205
205
  | `LDAP_WATCHDOG_REFRESH_RATE` | Time interval in seconds between consecutive LDAP searches | `60` |
206
+ | `LDAP_WATCHDOG_NETWORK_TIMEOUT` | Positive integer timeout in seconds for LDAP and Slack connection/read operations. This limits individual waits, not the total duration of a poll. | `30` |
206
207
  | `LDAP_WATCHDOG_DISABLE_COLOR_OUTPUT` | Set to `true` to disable colored terminal output | `false` |
207
208
 
209
+ LDAPS connections verify the server certificate and check that it is valid for the host name or IP address in `LDAP_WATCHDOG_SERVER`. For a private CA, set `LDAP_WATCHDOG_CA_CERTS_FILE` to a readable PEM CA bundle; in Docker, mount the bundle into the container and use its container path. A self-signed server certificate can be used as the bundle itself. If the certificate can't be provided or doesn't match the server's name, `LDAP_WATCHDOG_TLS_VERIFY=false` turns verification off; anyone on the network path can then impersonate the server and capture the bind password. For a server without TLS, use an `ldap://` URL or set `LDAP_WATCHDOG_USE_SSL=false`.
210
+
208
211
  ### Control User
209
212
 
210
213
  | Environment Variable | Description | Default |
211
214
  |---|---|---|
212
215
  | `LDAP_WATCHDOG_CONTROL_UUID` | UUID of a control user whose changes trigger an error if not found. If set, this user should always have some type of change when the LDAP directory is retrieved. | `""` |
213
- | `LDAP_WATCHDOG_CONTROL_USER_ATTRIBUTE` | Specific attribute to check for changes in the control user. If set, this attribute _must_ have changed for the control UUID user. | `""` |
216
+ | `LDAP_WATCHDOG_CONTROL_USER_ATTRIBUTE` | Specific attribute to check in the control user. If set, it must exist in both snapshots and its value must differ. | `""` |
217
+
218
+ A failed control check logs an error and skips that comparison. The next poll compares against the most recent successfully retrieved snapshot; changes from a rejected comparison are not replayed. A failed LDAP retrieval instead preserves the previous snapshot and is retried on the next poll. Failure to retrieve the initial snapshot stops the process.
214
219
 
215
220
  ### Slack Integration
216
221
 
@@ -219,12 +224,16 @@ All configuration is done via environment variables. The following variables are
219
224
  | `SLACK_WEBHOOK_URL` | Slack Webhook URL for notifications. Requires the `slack` extra (`pip install LDAP-Monitor[slack]`). | `None` |
220
225
  | `LDAP_WATCHDOG_SLACK_BULLETPOINT` | Bullet point character used in Slack and console output | ` \u2022 ` |
221
226
 
227
+ Slack delivery is best effort: HTTP and network failures are logged to stderr, and monitoring and console output continue. Failed notifications are not queued or retried.
228
+
222
229
  ### Ignored Entries and Attributes
223
230
 
231
+ These filters apply to attribute changes on entries present in both snapshots. Entry additions and deletions are always announced with all retrieved attributes, including for ignored UUIDs.
232
+
224
233
  | Environment Variable | Description | Default |
225
234
  |---|---|---|
226
- | `LDAP_WATCHDOG_IGNORED_UUIDS` | Comma-separated list of UUIDs to ignore during comparison | `""` |
227
- | `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes to ignore during comparison | `""` |
235
+ | `LDAP_WATCHDOG_IGNORED_UUIDS` | Comma-separated list of UUIDs whose attribute changes are ignored | `""` |
236
+ | `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes whose changes are ignored on existing entries | `""` |
228
237
  | `LDAP_WATCHDOG_CONDITIONAL_IGNORED_ATTRIBUTES` | JSON string of attributes to conditionally ignore (see below) | `{}` |
229
238
 
230
239
  ### Conditional Ignored Attributes
@@ -248,7 +257,9 @@ export LDAP_WATCHDOG_BASE_DN='dc=mouse,dc=com'
248
257
  export LDAP_WATCHDOG_USERNAME='Emily'
249
258
  export LDAP_WATCHDOG_PASSWORD='qwerty123'
250
259
  export LDAP_WATCHDOG_USE_SSL='true'
260
+ export LDAP_WATCHDOG_TLS_VERIFY='true'
251
261
  export LDAP_WATCHDOG_REFRESH_RATE='60'
262
+ export LDAP_WATCHDOG_NETWORK_TIMEOUT='30'
252
263
  export LDAP_WATCHDOG_DISABLE_COLOR_OUTPUT='false'
253
264
  export LDAP_WATCHDOG_IGNORED_UUIDS='e191c564-6e6d-42c1-ae51-bda0509fe846,8655e0d9-ecdc-46ce-ba42-1fa3dfbf5faa'
254
265
  export LDAP_WATCHDOG_IGNORED_ATTRIBUTES='modifyTimestamp,phoneNumber,officeLocation,gecos'
@@ -263,5 +274,16 @@ ldap-watchdog
263
274
  2. Similar to #1, there is no real way to determine whether a change is really a change in some cases, or a removal and then addition in the same time. In theory, it doesn't really matter, however it's important to note.
264
275
 
265
276
 
277
+ ## Development
278
+
279
+ Install the package with Slack support and run the regression tests:
280
+
281
+ ```bash
282
+ pip install ".[slack]"
283
+ python -m unittest discover -s tests
284
+ ```
285
+
286
+ `requirements.txt` pins every dependency for the Docker image and `install.sh`. After changing the dependencies in `setup.py`, regenerate it with `pip-compile --extra=slack --output-file=requirements.txt setup.py`.
287
+
266
288
  ## License
267
- This project is licensed under [GPL3.0](/LICENSE).
289
+ This project is licensed under [GPL3.0](https://github.com/MegaManSec/LDAP-Monitoring-Watchdog/blob/main/LICENSE).
@@ -11,4 +11,5 @@ LDAP_Monitor.egg-info/top_level.txt
11
11
  ldap_watchdog/__init__.py
12
12
  ldap_watchdog/__main__.py
13
13
  ldap_watchdog/config.py
14
- ldap_watchdog/watchdog.py
14
+ ldap_watchdog/watchdog.py
15
+ tests/test_watchdog.py
@@ -0,0 +1,5 @@
1
+ ldap3
2
+ pyasn1>=0.6.4
3
+
4
+ [slack]
5
+ requests>=2.33.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: LDAP-Monitor
3
- Version: 1.0.0
3
+ Version: 2.0.0
4
4
  Summary: Monitor LDAP directory changes in real-time
5
5
  Home-page: https://github.com/MegaManSec/LDAP-Monitoring-Watchdog
6
6
  Author: Joshua Rogers
@@ -9,9 +9,6 @@ License: GPL-3.0
9
9
  Project-URL: Blog Post, https://joshua.hu/ldap-watchdog-openldap-python-monitoring-tool-realtime-directory-slack-notifications
10
10
  Project-URL: Author, https://joshua.hu/
11
11
  Classifier: Programming Language :: Python :: 3
12
- Classifier: Programming Language :: Python :: 3.7
13
- Classifier: Programming Language :: Python :: 3.8
14
- Classifier: Programming Language :: Python :: 3.9
15
12
  Classifier: Programming Language :: Python :: 3.10
16
13
  Classifier: Programming Language :: Python :: 3.11
17
14
  Classifier: Programming Language :: Python :: 3.12
@@ -20,12 +17,13 @@ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
20
17
  Classifier: Operating System :: OS Independent
21
18
  Classifier: Topic :: System :: Monitoring
22
19
  Classifier: Topic :: System :: Systems Administration :: Authentication/Directory :: LDAP
23
- Requires-Python: >=3.7
20
+ Requires-Python: >=3.10
24
21
  Description-Content-Type: text/markdown
25
22
  License-File: LICENSE
26
23
  Requires-Dist: ldap3
24
+ Requires-Dist: pyasn1>=0.6.4
27
25
  Provides-Extra: slack
28
- Requires-Dist: requests; extra == "slack"
26
+ Requires-Dist: requests>=2.33.0; extra == "slack"
29
27
  Dynamic: author
30
28
  Dynamic: author-email
31
29
  Dynamic: classifier
@@ -66,15 +64,15 @@ Previously named LDAP-Stalker (because monitoring changes of an LDAP directory i
66
64
 
67
65
  ## Examples (No Filtering)
68
66
 
69
- Note: in the below examples, entryCSN and modifyTimestamp can be completely ignored by setting `LDAP_WATCHDOG_IGNORED_ATTRIBUTES=entryCSN,modifyTimestamp`.
67
+ Note: in the below examples, changes to entryCSN and modifyTimestamp on existing entries can be ignored by setting `LDAP_WATCHDOG_IGNORED_ATTRIBUTES=entryCSN,modifyTimestamp`.
70
68
 
71
69
  ### Terminal (with color) output:
72
70
 
73
- ![Example of the output from LDAP Watchdog](example.png "LDAP Watchdog")
71
+ ![Example of the output from LDAP Watchdog](https://raw.githubusercontent.com/MegaManSec/LDAP-Monitoring-Watchdog/main/example.png "LDAP Watchdog")
74
72
 
75
73
  ### Slack output:
76
74
 
77
- ![Example of the output from LDAP Watchdog in Slack](example-slack.png "LDAP Watchdog")
75
+ ![Example of the output from LDAP Watchdog in Slack](https://raw.githubusercontent.com/MegaManSec/LDAP-Monitoring-Watchdog/main/example-slack.png "LDAP Watchdog")
78
76
 
79
77
  ## Features
80
78
 
@@ -90,13 +88,13 @@ Note: in the below examples, entryCSN and modifyTimestamp can be completely igno
90
88
 
91
89
  6. **Customizable Output:** Console output provides clear and colored indications of additions, modifications, and deletions for easy visibility.
92
90
 
93
- 7. **Ignored Entries and Attributes:** Users can specify UUIDs and attributes to be ignored during the comparison process.
91
+ 7. **Ignored Entries and Attributes:** Users can specify UUIDs and attributes to ignore when comparing changes to existing entries.
94
92
 
95
93
  8. **Conditional Ignored Attributes:** Conditional filtering allows users to ignore specific attributes based on change type (additions, modifications, deletions).
96
94
 
97
95
 
98
96
  ## Requirements
99
- - Python 3.7+.
97
+ - Python 3.10+.
100
98
  - The `ldap3` package. If using a Slack webhook, the `requests` package is also required.
101
99
 
102
100
  ## Installation
@@ -166,10 +164,10 @@ docker run -d \
166
164
 
167
165
  ### Systemd Installation (Debian-based)
168
166
 
169
- A Debian-based installation script, [install.sh](install.sh), is provided. When run as root, this script:
167
+ A Debian-based installation script, [install.sh](https://github.com/MegaManSec/LDAP-Monitoring-Watchdog/blob/main/install.sh), is provided. When run as root from a clone of this repository, this script:
170
168
 
171
169
  1. Creates (if necessary) a Python virtual environment in `/opt/ldap-watchdog`.
172
- 2. Installs LDAP Watchdog from PyPI into that virtual environment.
170
+ 2. Installs LDAP Watchdog from the clone into that virtual environment, with the dependency versions pinned in `requirements.txt`.
173
171
  3. Creates an environment file at `/etc/ldap-watchdog.env` for configuration.
174
172
  4. Installs and enables a systemd service (`/etc/systemd/system/ldap-watchdog.service`) that runs **ldap-watchdog** in the background.
175
173
  5. Configures logging to `/var/log/ldap-watchdog.log` and `/var/log/ldap-watchdog-error.log`.
@@ -200,17 +198,24 @@ All configuration is done via environment variables. The following variables are
200
198
  | `LDAP_WATCHDOG_USERNAME` | LDAP username for authentication. Leave empty for anonymous bind. | `""` |
201
199
  | `LDAP_WATCHDOG_PASSWORD` | LDAP password for authentication. Leave empty for anonymous bind. | `""` |
202
200
  | `LDAP_WATCHDOG_USE_SSL` | Set to `true` to use SSL, `false` otherwise | `true` |
201
+ | `LDAP_WATCHDOG_CA_CERTS_FILE` | Optional PEM CA bundle for verifying the LDAP server certificate. If unset, system trust roots are used. | Unset |
202
+ | `LDAP_WATCHDOG_TLS_VERIFY` | Set to `false` to skip verifying the LDAP server certificate and host name. The connection is still encrypted but not authenticated. | `true` |
203
203
  | `LDAP_WATCHDOG_SEARCH_FILTER` | LDAP filter for user and group entries | `(&(|(objectClass=inetOrgPerson)(objectClass=groupOfNames)))` |
204
- | `LDAP_WATCHDOG_SEARCH_ATTRIBUTE` | Comma-separated list of attributes to retrieve. [`*,+`](https://joshua.hu/tracking-secret-ldap-login-times-with-modifytimestamp-heuristics) is used by default to include operational attributes. | `*,+` |
204
+ | `LDAP_WATCHDOG_SEARCH_ATTRIBUTE` | Comma-separated list of attributes to retrieve. [`*,+`](https://joshua.hu/tracking-secret-ldap-login-times-with-modifytimestamp-heuristics) is used by default to include operational attributes. `entryUUID` is always requested and must be readable for every entry. | `*,+` |
205
205
  | `LDAP_WATCHDOG_REFRESH_RATE` | Time interval in seconds between consecutive LDAP searches | `60` |
206
+ | `LDAP_WATCHDOG_NETWORK_TIMEOUT` | Positive integer timeout in seconds for LDAP and Slack connection/read operations. This limits individual waits, not the total duration of a poll. | `30` |
206
207
  | `LDAP_WATCHDOG_DISABLE_COLOR_OUTPUT` | Set to `true` to disable colored terminal output | `false` |
207
208
 
209
+ LDAPS connections verify the server certificate and check that it is valid for the host name or IP address in `LDAP_WATCHDOG_SERVER`. For a private CA, set `LDAP_WATCHDOG_CA_CERTS_FILE` to a readable PEM CA bundle; in Docker, mount the bundle into the container and use its container path. A self-signed server certificate can be used as the bundle itself. If the certificate can't be provided or doesn't match the server's name, `LDAP_WATCHDOG_TLS_VERIFY=false` turns verification off; anyone on the network path can then impersonate the server and capture the bind password. For a server without TLS, use an `ldap://` URL or set `LDAP_WATCHDOG_USE_SSL=false`.
210
+
208
211
  ### Control User
209
212
 
210
213
  | Environment Variable | Description | Default |
211
214
  |---|---|---|
212
215
  | `LDAP_WATCHDOG_CONTROL_UUID` | UUID of a control user whose changes trigger an error if not found. If set, this user should always have some type of change when the LDAP directory is retrieved. | `""` |
213
- | `LDAP_WATCHDOG_CONTROL_USER_ATTRIBUTE` | Specific attribute to check for changes in the control user. If set, this attribute _must_ have changed for the control UUID user. | `""` |
216
+ | `LDAP_WATCHDOG_CONTROL_USER_ATTRIBUTE` | Specific attribute to check in the control user. If set, it must exist in both snapshots and its value must differ. | `""` |
217
+
218
+ A failed control check logs an error and skips that comparison. The next poll compares against the most recent successfully retrieved snapshot; changes from a rejected comparison are not replayed. A failed LDAP retrieval instead preserves the previous snapshot and is retried on the next poll. Failure to retrieve the initial snapshot stops the process.
214
219
 
215
220
  ### Slack Integration
216
221
 
@@ -219,12 +224,16 @@ All configuration is done via environment variables. The following variables are
219
224
  | `SLACK_WEBHOOK_URL` | Slack Webhook URL for notifications. Requires the `slack` extra (`pip install LDAP-Monitor[slack]`). | `None` |
220
225
  | `LDAP_WATCHDOG_SLACK_BULLETPOINT` | Bullet point character used in Slack and console output | ` \u2022 ` |
221
226
 
227
+ Slack delivery is best effort: HTTP and network failures are logged to stderr, and monitoring and console output continue. Failed notifications are not queued or retried.
228
+
222
229
  ### Ignored Entries and Attributes
223
230
 
231
+ These filters apply to attribute changes on entries present in both snapshots. Entry additions and deletions are always announced with all retrieved attributes, including for ignored UUIDs.
232
+
224
233
  | Environment Variable | Description | Default |
225
234
  |---|---|---|
226
- | `LDAP_WATCHDOG_IGNORED_UUIDS` | Comma-separated list of UUIDs to ignore during comparison | `""` |
227
- | `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes to ignore during comparison | `""` |
235
+ | `LDAP_WATCHDOG_IGNORED_UUIDS` | Comma-separated list of UUIDs whose attribute changes are ignored | `""` |
236
+ | `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes whose changes are ignored on existing entries | `""` |
228
237
  | `LDAP_WATCHDOG_CONDITIONAL_IGNORED_ATTRIBUTES` | JSON string of attributes to conditionally ignore (see below) | `{}` |
229
238
 
230
239
  ### Conditional Ignored Attributes
@@ -248,7 +257,9 @@ export LDAP_WATCHDOG_BASE_DN='dc=mouse,dc=com'
248
257
  export LDAP_WATCHDOG_USERNAME='Emily'
249
258
  export LDAP_WATCHDOG_PASSWORD='qwerty123'
250
259
  export LDAP_WATCHDOG_USE_SSL='true'
260
+ export LDAP_WATCHDOG_TLS_VERIFY='true'
251
261
  export LDAP_WATCHDOG_REFRESH_RATE='60'
262
+ export LDAP_WATCHDOG_NETWORK_TIMEOUT='30'
252
263
  export LDAP_WATCHDOG_DISABLE_COLOR_OUTPUT='false'
253
264
  export LDAP_WATCHDOG_IGNORED_UUIDS='e191c564-6e6d-42c1-ae51-bda0509fe846,8655e0d9-ecdc-46ce-ba42-1fa3dfbf5faa'
254
265
  export LDAP_WATCHDOG_IGNORED_ATTRIBUTES='modifyTimestamp,phoneNumber,officeLocation,gecos'
@@ -263,5 +274,16 @@ ldap-watchdog
263
274
  2. Similar to #1, there is no real way to determine whether a change is really a change in some cases, or a removal and then addition in the same time. In theory, it doesn't really matter, however it's important to note.
264
275
 
265
276
 
277
+ ## Development
278
+
279
+ Install the package with Slack support and run the regression tests:
280
+
281
+ ```bash
282
+ pip install ".[slack]"
283
+ python -m unittest discover -s tests
284
+ ```
285
+
286
+ `requirements.txt` pins every dependency for the Docker image and `install.sh`. After changing the dependencies in `setup.py`, regenerate it with `pip-compile --extra=slack --output-file=requirements.txt setup.py`.
287
+
266
288
  ## License
267
- This project is licensed under [GPL3.0](/LICENSE).
289
+ This project is licensed under [GPL3.0](https://github.com/MegaManSec/LDAP-Monitoring-Watchdog/blob/main/LICENSE).
@@ -24,15 +24,15 @@ Previously named LDAP-Stalker (because monitoring changes of an LDAP directory i
24
24
 
25
25
  ## Examples (No Filtering)
26
26
 
27
- Note: in the below examples, entryCSN and modifyTimestamp can be completely ignored by setting `LDAP_WATCHDOG_IGNORED_ATTRIBUTES=entryCSN,modifyTimestamp`.
27
+ Note: in the below examples, changes to entryCSN and modifyTimestamp on existing entries can be ignored by setting `LDAP_WATCHDOG_IGNORED_ATTRIBUTES=entryCSN,modifyTimestamp`.
28
28
 
29
29
  ### Terminal (with color) output:
30
30
 
31
- ![Example of the output from LDAP Watchdog](example.png "LDAP Watchdog")
31
+ ![Example of the output from LDAP Watchdog](https://raw.githubusercontent.com/MegaManSec/LDAP-Monitoring-Watchdog/main/example.png "LDAP Watchdog")
32
32
 
33
33
  ### Slack output:
34
34
 
35
- ![Example of the output from LDAP Watchdog in Slack](example-slack.png "LDAP Watchdog")
35
+ ![Example of the output from LDAP Watchdog in Slack](https://raw.githubusercontent.com/MegaManSec/LDAP-Monitoring-Watchdog/main/example-slack.png "LDAP Watchdog")
36
36
 
37
37
  ## Features
38
38
 
@@ -48,13 +48,13 @@ Note: in the below examples, entryCSN and modifyTimestamp can be completely igno
48
48
 
49
49
  6. **Customizable Output:** Console output provides clear and colored indications of additions, modifications, and deletions for easy visibility.
50
50
 
51
- 7. **Ignored Entries and Attributes:** Users can specify UUIDs and attributes to be ignored during the comparison process.
51
+ 7. **Ignored Entries and Attributes:** Users can specify UUIDs and attributes to ignore when comparing changes to existing entries.
52
52
 
53
53
  8. **Conditional Ignored Attributes:** Conditional filtering allows users to ignore specific attributes based on change type (additions, modifications, deletions).
54
54
 
55
55
 
56
56
  ## Requirements
57
- - Python 3.7+.
57
+ - Python 3.10+.
58
58
  - The `ldap3` package. If using a Slack webhook, the `requests` package is also required.
59
59
 
60
60
  ## Installation
@@ -124,10 +124,10 @@ docker run -d \
124
124
 
125
125
  ### Systemd Installation (Debian-based)
126
126
 
127
- A Debian-based installation script, [install.sh](install.sh), is provided. When run as root, this script:
127
+ A Debian-based installation script, [install.sh](https://github.com/MegaManSec/LDAP-Monitoring-Watchdog/blob/main/install.sh), is provided. When run as root from a clone of this repository, this script:
128
128
 
129
129
  1. Creates (if necessary) a Python virtual environment in `/opt/ldap-watchdog`.
130
- 2. Installs LDAP Watchdog from PyPI into that virtual environment.
130
+ 2. Installs LDAP Watchdog from the clone into that virtual environment, with the dependency versions pinned in `requirements.txt`.
131
131
  3. Creates an environment file at `/etc/ldap-watchdog.env` for configuration.
132
132
  4. Installs and enables a systemd service (`/etc/systemd/system/ldap-watchdog.service`) that runs **ldap-watchdog** in the background.
133
133
  5. Configures logging to `/var/log/ldap-watchdog.log` and `/var/log/ldap-watchdog-error.log`.
@@ -158,17 +158,24 @@ All configuration is done via environment variables. The following variables are
158
158
  | `LDAP_WATCHDOG_USERNAME` | LDAP username for authentication. Leave empty for anonymous bind. | `""` |
159
159
  | `LDAP_WATCHDOG_PASSWORD` | LDAP password for authentication. Leave empty for anonymous bind. | `""` |
160
160
  | `LDAP_WATCHDOG_USE_SSL` | Set to `true` to use SSL, `false` otherwise | `true` |
161
+ | `LDAP_WATCHDOG_CA_CERTS_FILE` | Optional PEM CA bundle for verifying the LDAP server certificate. If unset, system trust roots are used. | Unset |
162
+ | `LDAP_WATCHDOG_TLS_VERIFY` | Set to `false` to skip verifying the LDAP server certificate and host name. The connection is still encrypted but not authenticated. | `true` |
161
163
  | `LDAP_WATCHDOG_SEARCH_FILTER` | LDAP filter for user and group entries | `(&(|(objectClass=inetOrgPerson)(objectClass=groupOfNames)))` |
162
- | `LDAP_WATCHDOG_SEARCH_ATTRIBUTE` | Comma-separated list of attributes to retrieve. [`*,+`](https://joshua.hu/tracking-secret-ldap-login-times-with-modifytimestamp-heuristics) is used by default to include operational attributes. | `*,+` |
164
+ | `LDAP_WATCHDOG_SEARCH_ATTRIBUTE` | Comma-separated list of attributes to retrieve. [`*,+`](https://joshua.hu/tracking-secret-ldap-login-times-with-modifytimestamp-heuristics) is used by default to include operational attributes. `entryUUID` is always requested and must be readable for every entry. | `*,+` |
163
165
  | `LDAP_WATCHDOG_REFRESH_RATE` | Time interval in seconds between consecutive LDAP searches | `60` |
166
+ | `LDAP_WATCHDOG_NETWORK_TIMEOUT` | Positive integer timeout in seconds for LDAP and Slack connection/read operations. This limits individual waits, not the total duration of a poll. | `30` |
164
167
  | `LDAP_WATCHDOG_DISABLE_COLOR_OUTPUT` | Set to `true` to disable colored terminal output | `false` |
165
168
 
169
+ LDAPS connections verify the server certificate and check that it is valid for the host name or IP address in `LDAP_WATCHDOG_SERVER`. For a private CA, set `LDAP_WATCHDOG_CA_CERTS_FILE` to a readable PEM CA bundle; in Docker, mount the bundle into the container and use its container path. A self-signed server certificate can be used as the bundle itself. If the certificate can't be provided or doesn't match the server's name, `LDAP_WATCHDOG_TLS_VERIFY=false` turns verification off; anyone on the network path can then impersonate the server and capture the bind password. For a server without TLS, use an `ldap://` URL or set `LDAP_WATCHDOG_USE_SSL=false`.
170
+
166
171
  ### Control User
167
172
 
168
173
  | Environment Variable | Description | Default |
169
174
  |---|---|---|
170
175
  | `LDAP_WATCHDOG_CONTROL_UUID` | UUID of a control user whose changes trigger an error if not found. If set, this user should always have some type of change when the LDAP directory is retrieved. | `""` |
171
- | `LDAP_WATCHDOG_CONTROL_USER_ATTRIBUTE` | Specific attribute to check for changes in the control user. If set, this attribute _must_ have changed for the control UUID user. | `""` |
176
+ | `LDAP_WATCHDOG_CONTROL_USER_ATTRIBUTE` | Specific attribute to check in the control user. If set, it must exist in both snapshots and its value must differ. | `""` |
177
+
178
+ A failed control check logs an error and skips that comparison. The next poll compares against the most recent successfully retrieved snapshot; changes from a rejected comparison are not replayed. A failed LDAP retrieval instead preserves the previous snapshot and is retried on the next poll. Failure to retrieve the initial snapshot stops the process.
172
179
 
173
180
  ### Slack Integration
174
181
 
@@ -177,12 +184,16 @@ All configuration is done via environment variables. The following variables are
177
184
  | `SLACK_WEBHOOK_URL` | Slack Webhook URL for notifications. Requires the `slack` extra (`pip install LDAP-Monitor[slack]`). | `None` |
178
185
  | `LDAP_WATCHDOG_SLACK_BULLETPOINT` | Bullet point character used in Slack and console output | ` \u2022 ` |
179
186
 
187
+ Slack delivery is best effort: HTTP and network failures are logged to stderr, and monitoring and console output continue. Failed notifications are not queued or retried.
188
+
180
189
  ### Ignored Entries and Attributes
181
190
 
191
+ These filters apply to attribute changes on entries present in both snapshots. Entry additions and deletions are always announced with all retrieved attributes, including for ignored UUIDs.
192
+
182
193
  | Environment Variable | Description | Default |
183
194
  |---|---|---|
184
- | `LDAP_WATCHDOG_IGNORED_UUIDS` | Comma-separated list of UUIDs to ignore during comparison | `""` |
185
- | `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes to ignore during comparison | `""` |
195
+ | `LDAP_WATCHDOG_IGNORED_UUIDS` | Comma-separated list of UUIDs whose attribute changes are ignored | `""` |
196
+ | `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes whose changes are ignored on existing entries | `""` |
186
197
  | `LDAP_WATCHDOG_CONDITIONAL_IGNORED_ATTRIBUTES` | JSON string of attributes to conditionally ignore (see below) | `{}` |
187
198
 
188
199
  ### Conditional Ignored Attributes
@@ -206,7 +217,9 @@ export LDAP_WATCHDOG_BASE_DN='dc=mouse,dc=com'
206
217
  export LDAP_WATCHDOG_USERNAME='Emily'
207
218
  export LDAP_WATCHDOG_PASSWORD='qwerty123'
208
219
  export LDAP_WATCHDOG_USE_SSL='true'
220
+ export LDAP_WATCHDOG_TLS_VERIFY='true'
209
221
  export LDAP_WATCHDOG_REFRESH_RATE='60'
222
+ export LDAP_WATCHDOG_NETWORK_TIMEOUT='30'
210
223
  export LDAP_WATCHDOG_DISABLE_COLOR_OUTPUT='false'
211
224
  export LDAP_WATCHDOG_IGNORED_UUIDS='e191c564-6e6d-42c1-ae51-bda0509fe846,8655e0d9-ecdc-46ce-ba42-1fa3dfbf5faa'
212
225
  export LDAP_WATCHDOG_IGNORED_ATTRIBUTES='modifyTimestamp,phoneNumber,officeLocation,gecos'
@@ -221,5 +234,16 @@ ldap-watchdog
221
234
  2. Similar to #1, there is no real way to determine whether a change is really a change in some cases, or a removal and then addition in the same time. In theory, it doesn't really matter, however it's important to note.
222
235
 
223
236
 
237
+ ## Development
238
+
239
+ Install the package with Slack support and run the regression tests:
240
+
241
+ ```bash
242
+ pip install ".[slack]"
243
+ python -m unittest discover -s tests
244
+ ```
245
+
246
+ `requirements.txt` pins every dependency for the Docker image and `install.sh`. After changing the dependencies in `setup.py`, regenerate it with `pip-compile --extra=slack --output-file=requirements.txt setup.py`.
247
+
224
248
  ## License
225
- This project is licensed under [GPL3.0](/LICENSE).
249
+ This project is licensed under [GPL3.0](https://github.com/MegaManSec/LDAP-Monitoring-Watchdog/blob/main/LICENSE).
@@ -1,2 +1,2 @@
1
1
  """LDAP Watchdog - Monitor LDAP directory changes in real-time."""
2
- __version__ = "1.0.0"
2
+ __version__ = "2.0.0"
@@ -28,6 +28,8 @@ LDAP_SERVER = os.getenv('LDAP_WATCHDOG_SERVER', '')
28
28
  LDAP_USERNAME = os.getenv('LDAP_WATCHDOG_USERNAME', '')
29
29
  LDAP_PASSWORD = os.getenv('LDAP_WATCHDOG_PASSWORD', '')
30
30
  LDAP_USE_SSL = _parse_bool(os.getenv('LDAP_WATCHDOG_USE_SSL', 'true'))
31
+ LDAP_CA_CERTS_FILE = os.getenv('LDAP_WATCHDOG_CA_CERTS_FILE') or None
32
+ LDAP_TLS_VERIFY = _parse_bool(os.getenv('LDAP_WATCHDOG_TLS_VERIFY', 'true'))
31
33
  BASE_DN = os.getenv('LDAP_WATCHDOG_BASE_DN', '')
32
34
 
33
35
  DISABLE_COLOR_OUTPUT = _parse_bool(os.getenv('LDAP_WATCHDOG_DISABLE_COLOR_OUTPUT', 'false'))
@@ -36,6 +38,9 @@ SEARCH_FILTER = os.getenv('LDAP_WATCHDOG_SEARCH_FILTER', '(&(|(objectClass=inetO
36
38
  SEARCH_ATTRIBUTE = _parse_list(os.getenv('LDAP_WATCHDOG_SEARCH_ATTRIBUTE', '*,+'))
37
39
 
38
40
  REFRESH_RATE = int(os.getenv('LDAP_WATCHDOG_REFRESH_RATE', '60'))
41
+ NETWORK_TIMEOUT = int(os.getenv('LDAP_WATCHDOG_NETWORK_TIMEOUT', '30'))
42
+ if NETWORK_TIMEOUT <= 0:
43
+ raise ValueError('LDAP_WATCHDOG_NETWORK_TIMEOUT must be greater than zero')
39
44
 
40
45
  SLACK_BULLETPOINT = os.getenv('LDAP_WATCHDOG_SLACK_BULLETPOINT', ' \u2022 ')
41
46
 
@@ -4,12 +4,15 @@
4
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
5
 
6
6
  import json
7
+ import re
8
+ import ssl
7
9
  import sys
10
+ from collections import Counter
8
11
  from datetime import datetime
9
12
  import time
10
13
 
11
- from ldap3 import Server, Connection, ALL, SUBTREE
12
- from ldap3.core.exceptions import LDAPSocketOpenError
14
+ from ldap3 import Server, Connection, Tls, ALL, SUBTREE
15
+ from ldap3.core.exceptions import LDAPException
13
16
 
14
17
  from ldap_watchdog import config
15
18
 
@@ -32,6 +35,19 @@ def col(op_type):
32
35
  return {'add': "\033[1m\033[32m", 'delete': "\033[3m\033[31m", 'modify': "\033[33m"}[op_type]
33
36
 
34
37
 
38
+ class VerifiedTls(Tls):
39
+ """
40
+ ldap3 TLS settings that verify the server certificate and hostname with the ssl module's default context.
41
+ """
42
+
43
+ def wrap_socket(self, connection, do_handshake=False):
44
+ context = ssl.create_default_context(cafile=self.ca_certs_file)
45
+ context.verify_flags &= ~ssl.VERIFY_X509_STRICT
46
+ connection.socket = context.wrap_socket(
47
+ connection.socket, server_hostname=connection.server.host, do_handshake_on_connect=do_handshake,
48
+ )
49
+
50
+
35
51
  def retrieve_ldap():
36
52
  """
37
53
  Connects to the LDAP server and retrieves LDAP entries.
@@ -40,33 +56,57 @@ def retrieve_ldap():
40
56
  - dict: Dictionary containing LDAP entries.
41
57
  """
42
58
  entries = {}
43
- server = Server(config.LDAP_SERVER, use_ssl=config.LDAP_USE_SSL, get_info=ALL)
59
+ if config.LDAP_TLS_VERIFY:
60
+ tls = VerifiedTls(validate=ssl.CERT_REQUIRED, ca_certs_file=config.LDAP_CA_CERTS_FILE)
61
+ else:
62
+ tls = Tls(validate=ssl.CERT_NONE)
63
+ server = Server(
64
+ config.LDAP_SERVER, use_ssl=config.LDAP_USE_SSL, get_info=ALL,
65
+ tls=tls, connect_timeout=config.NETWORK_TIMEOUT,
66
+ )
44
67
  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
68
+ conn = Connection(server, user=config.LDAP_USERNAME, password=config.LDAP_PASSWORD,
69
+ receive_timeout=config.NETWORK_TIMEOUT)
49
70
  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
71
+ conn = Connection(server, receive_timeout=config.NETWORK_TIMEOUT)
67
72
 
68
- entry_dict['dn'] = [entry['dn']]
69
- entries[entry_dict['entryUUID'][0]] = entry_dict
73
+ try:
74
+ if not conn.bind():
75
+ raise LDAPException(f"LDAP bind failed: {conn.result}")
76
+
77
+ cookie = None
78
+ while True:
79
+ conn.search(
80
+ search_base=config.BASE_DN, search_filter=config.SEARCH_FILTER,
81
+ search_scope=SUBTREE, attributes=config.SEARCH_ATTRIBUTE + ['entryUUID'],
82
+ paged_size=500, paged_cookie=cookie,
83
+ )
84
+ # A successful search can return no entries; check the result code.
85
+ if conn.result['result'] != 0:
86
+ raise LDAPException(f"LDAP search failed: {conn.result}")
87
+
88
+ for entry in conn.entries:
89
+ entry = json.loads(entry.entry_to_json())
90
+ entry_dict = entry['attributes']
91
+ for attr_name, values in entry_dict.items():
92
+ # Keep every value, including binary data represented as Base64 by ldap3.
93
+ entry_dict[attr_name] = [
94
+ value['encoded']
95
+ if isinstance(value, dict) and value.get('encoding') == 'base64'
96
+ else value
97
+ for value in values
98
+ ]
99
+
100
+ entry_dict['dn'] = [entry['dn']]
101
+ if not entry_dict.get('entryUUID'):
102
+ raise LDAPException(f"LDAP entry {entry['dn']} has no readable entryUUID")
103
+ entries[entry_dict['entryUUID'][0]] = entry_dict
104
+
105
+ cookie = conn.result.get('controls', {}).get('1.2.840.113556.1.4.319', {}).get('value', {}).get('cookie')
106
+ if not cookie:
107
+ break
108
+ finally:
109
+ conn.unbind()
70
110
 
71
111
  return entries
72
112
 
@@ -138,8 +178,8 @@ def announce(dn_uuid, op_type, changes):
138
178
  - None
139
179
  """
140
180
  slack_msg, print_msg = generate_message(dn_uuid, op_type, changes)
181
+ print(print_msg, flush=True)
141
182
  send_to_slack(slack_msg)
142
- print(print_msg)
143
183
 
144
184
 
145
185
  def truncate_slack_message(message):
@@ -152,10 +192,23 @@ def truncate_slack_message(message):
152
192
  Returns:
153
193
  - str: Truncated Slack message.
154
194
  """
155
- while len(message) > 4000:
156
- longest_word = max(message.split(), key=len)
157
- message = message.replace(longest_word, "[...truncated...]")
195
+ marker = "[...truncated...]"
196
+ header, newline, _ = message.partition("\n")
197
+ protected = set(header.split()) if newline else set()
198
+ words = Counter(message.split())
199
+ length = len(message)
200
+ replaced = set()
201
+ for word in sorted(words, key=len, reverse=True):
202
+ if length <= 4000 or len(word) <= len(marker):
203
+ break
204
+ if word in protected:
205
+ continue
206
+ replaced.add(word)
207
+ length -= words[word] * (len(word) - len(marker))
158
208
 
209
+ message = re.sub(r'\S+', lambda match: marker if match.group() in replaced else match.group(), message)
210
+ if len(message) > 4000:
211
+ return message[:4000 - len(marker)] + marker
159
212
  return message
160
213
 
161
214
 
@@ -174,9 +227,13 @@ def send_to_slack(message):
174
227
 
175
228
  message = truncate_slack_message(message)
176
229
 
177
- headers = {'Content-type': 'application/json'}
178
- data = {'text': message}
179
- requests.post(config.SLACK_WEBHOOK, headers=headers, data=json.dumps(data))
230
+ try:
231
+ response = requests.post(config.SLACK_WEBHOOK, json={'text': message}, timeout=config.NETWORK_TIMEOUT)
232
+ response.raise_for_status()
233
+ except requests.RequestException as e:
234
+ # Exception messages can contain the secret webhook URL.
235
+ detail = f"HTTP {e.response.status_code}" if e.response is not None else type(e).__name__
236
+ print(f"Slack notification failed: {detail}", file=sys.stderr)
180
237
 
181
238
 
182
239
  def check_control_user(old_entries, new_entries):
@@ -253,10 +310,10 @@ def compare_ldap_entries(old_entries, new_entries):
253
310
  if old_value != new_value:
254
311
  if old_value is None:
255
312
  # If the key is not found in old_entries, it's an addition.
256
- changes["additions"].append({key: new_value})
313
+ changes["additions"].append({key: list(new_value)})
257
314
  elif new_value is None:
258
315
  # If the key is not found in new_entries, it's a removal.
259
- changes["removals"].append({key: old_value})
316
+ changes["removals"].append({key: list(old_value)})
260
317
  else:
261
318
  # If the key is in both old_entries and new_entries but the values are not the same, then may be a modification.
262
319
  # There is no way to truly determine whether an attribute's value(s) have been changed, or removed and then a new value added.
@@ -310,7 +367,7 @@ def compare_ldap_entries(old_entries, new_entries):
310
367
  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
368
  changes[change_type].remove(change) # Remove the whole dictionary from changes["modifications"].
312
369
  else:
313
- for added_or_removed_val in change[ignored_attr_name][:]: # val1, val2, ...
370
+ for added_or_removed_val in change[ignored_attr_name].copy(): # val1, val2, ...
314
371
  if added_or_removed_val in ignored_attr_list: # Check whether the value should be ignored.
315
372
  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
373
  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.
@@ -330,8 +387,8 @@ def main():
330
387
 
331
388
  try:
332
389
  retrieved_entries = retrieve_ldap()
333
- except LDAPSocketOpenError as e:
334
- print(f"LDAP connection error: {e}", file=sys.stderr)
390
+ except LDAPException as e:
391
+ print(f"LDAP retrieval error: {e}", file=sys.stderr)
335
392
  continue
336
393
 
337
394
  old_entries = new_entries
@@ -22,12 +22,13 @@ setup(
22
22
  },
23
23
  license="GPL-3.0",
24
24
  packages=find_packages(),
25
- python_requires=">=3.7",
25
+ python_requires=">=3.10",
26
26
  install_requires=[
27
27
  "ldap3",
28
+ "pyasn1>=0.6.4",
28
29
  ],
29
30
  extras_require={
30
- "slack": ["requests"],
31
+ "slack": ["requests>=2.33.0"],
31
32
  },
32
33
  entry_points={
33
34
  "console_scripts": [
@@ -36,9 +37,6 @@ setup(
36
37
  },
37
38
  classifiers=[
38
39
  "Programming Language :: Python :: 3",
39
- "Programming Language :: Python :: 3.7",
40
- "Programming Language :: Python :: 3.8",
41
- "Programming Language :: Python :: 3.9",
42
40
  "Programming Language :: Python :: 3.10",
43
41
  "Programming Language :: Python :: 3.11",
44
42
  "Programming Language :: Python :: 3.12",
@@ -0,0 +1,325 @@
1
+ import copy
2
+ import io
3
+ import json
4
+ import os
5
+ import socket
6
+ import ssl
7
+ import threading
8
+ import time
9
+ import unittest
10
+ from contextlib import redirect_stderr, redirect_stdout
11
+ from unittest.mock import Mock, patch
12
+
13
+ from ldap3 import Connection, MOCK_SYNC, OFFLINE_SLAPD_2_4, Server
14
+ from ldap3.core.exceptions import LDAPException, LDAPSocketReceiveError
15
+ import requests
16
+
17
+ from ldap_watchdog import config, watchdog
18
+
19
+
20
+ UUID = '11111111-1111-1111-1111-111111111111'
21
+ DN = 'cn=Alice,dc=example,dc=com'
22
+ CERTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'certs')
23
+
24
+
25
+ def snapshot(**attributes):
26
+ return {UUID: dict(entryUUID=[UUID], dn=[DN], cn=['Alice'], **attributes)}
27
+
28
+
29
+ class ComparisonTests(unittest.TestCase):
30
+ def setUp(self):
31
+ settings = patch.multiple(
32
+ config, CONTROL_UUID='', IGNORED_UUIDS=[], IGNORED_ATTRIBUTES=[],
33
+ CONDITIONAL_IGNORED_ATTRIBUTES={'memberOf': ['ignored']},
34
+ )
35
+ settings.start()
36
+ self.addCleanup(settings.stop)
37
+
38
+ def test_conditional_filter_preserves_unignored_multivalued_changes(self):
39
+ for change_type, old, new in (
40
+ ('additions', ['existing'], ['existing', 'ignored', 'important']),
41
+ ('removals', ['existing', 'ignored', 'important'], ['existing']),
42
+ ):
43
+ with self.subTest(change_type=change_type):
44
+ with patch.object(watchdog, 'announce') as announce, redirect_stderr(io.StringIO()):
45
+ watchdog.compare_ldap_entries(snapshot(memberOf=old), snapshot(memberOf=new))
46
+ announce.assert_called_once()
47
+ self.assertEqual(announce.call_args[0][2][change_type], [{'memberOf': {'important'}}])
48
+
49
+ def test_conditional_filter_suppresses_only_ignored_changes(self):
50
+ with patch.object(watchdog, 'announce') as announce, redirect_stderr(io.StringIO()):
51
+ watchdog.compare_ldap_entries(
52
+ snapshot(memberOf=['existing']), snapshot(memberOf=['existing', 'ignored']),
53
+ )
54
+ announce.assert_not_called()
55
+
56
+ def test_conditional_filter_leaves_snapshots_unchanged(self):
57
+ for change_type, old, new in (
58
+ ('additions', snapshot(), snapshot(memberOf=['ignored', 'important'])),
59
+ ('removals', snapshot(memberOf=['ignored', 'important']), snapshot()),
60
+ ):
61
+ with self.subTest(change_type=change_type):
62
+ expected = copy.deepcopy((old, new))
63
+ with patch.object(watchdog, 'announce') as announce, redirect_stderr(io.StringIO()):
64
+ watchdog.compare_ldap_entries(old, new)
65
+ self.assertEqual(announce.call_args[0][2][change_type], [{'memberOf': ['important']}])
66
+ self.assertEqual((old, new), expected)
67
+
68
+
69
+ class MessageTests(unittest.TestCase):
70
+ def test_short_messages_are_unchanged(self):
71
+ for message in ('', 'hello', 'x' * 4000):
72
+ self.assertEqual(watchdog.truncate_slack_message(message), message)
73
+
74
+ def test_truncation_handles_many_short_words_and_whitespace(self):
75
+ group_message, _ = watchdog.generate_message(DN, 'add', {
76
+ 'member': [f'uid=user{i},ou=People,dc=example,dc=com' for i in range(100)],
77
+ })
78
+ for message in ('word ' * 1000, ' ' * 5000, group_message):
79
+ with self.subTest(length=len(message)):
80
+ truncated = watchdog.truncate_slack_message(message)
81
+ self.assertLessEqual(len(truncated), 4000)
82
+ self.assertIn('[...truncated...]', truncated)
83
+
84
+ def test_truncation_preserves_text_after_a_large_value(self):
85
+ message = 'photo ' + 'x' * 5000 + ' important change'
86
+ self.assertEqual(
87
+ watchdog.truncate_slack_message(message),
88
+ 'photo [...truncated...] important change',
89
+ )
90
+
91
+ def test_truncation_keeps_the_changed_entry_in_the_header(self):
92
+ for op_type in ('add', 'delete'):
93
+ message, _ = watchdog.generate_message(DN, op_type, {
94
+ 'member': [f'uid=user{i},ou=People,dc=example,dc=com' for i in range(100)],
95
+ })
96
+ with self.subTest(op_type=op_type):
97
+ header = message.partition('\n')[0]
98
+ self.assertIn(DN, header)
99
+ self.assertEqual(watchdog.truncate_slack_message(message).partition('\n')[0], header)
100
+
101
+ def test_truncation_replaces_the_longest_words_first(self):
102
+ for words, expected in (
103
+ (['b' * 2500, 'a' * 2500, 'c' * 100], ['[...truncated...]', 'a' * 2500, 'c' * 100]),
104
+ (['x' * 2000, 'y' * 600, 'x' * 2000, 'z' * 20], ['[...truncated...]', 'y' * 600, '[...truncated...]', 'z' * 20]),
105
+ ):
106
+ with self.subTest(words=[len(word) for word in words]):
107
+ self.assertEqual(watchdog.truncate_slack_message(' '.join(words)), ' '.join(expected))
108
+
109
+ def test_truncation_of_large_groups_is_fast(self):
110
+ message, _ = watchdog.generate_message(DN, 'delete', {
111
+ 'member': [f'uid=user{i:06d},ou=People,dc=example,dc=com' for i in range(8000)],
112
+ })
113
+ start = time.perf_counter()
114
+ truncated = watchdog.truncate_slack_message(message)
115
+ self.assertLess(time.perf_counter() - start, 1)
116
+ self.assertEqual(len(truncated), 4000)
117
+
118
+
119
+ class RetrievalTests(unittest.TestCase):
120
+ def setUp(self):
121
+ settings = patch.multiple(
122
+ config, LDAP_SERVER='mock', LDAP_USERNAME='', LDAP_PASSWORD='',
123
+ BASE_DN='dc=example,dc=com', SEARCH_FILTER='(objectClass=inetOrgPerson)',
124
+ SEARCH_ATTRIBUTE=['*', '+'], CONTROL_UUID='', IGNORED_UUIDS=[],
125
+ IGNORED_ATTRIBUTES=[], CONDITIONAL_IGNORED_ATTRIBUTES={},
126
+ )
127
+ settings.start()
128
+ self.addCleanup(settings.stop)
129
+
130
+ def retrieve(self, **attributes):
131
+ connection = Connection(Server('mock', get_info=OFFLINE_SLAPD_2_4), client_strategy=MOCK_SYNC)
132
+ entry = dict(
133
+ objectClass=['inetOrgPerson'], cn=['Alice'], sn=['Example'],
134
+ entryUUID=[UUID],
135
+ )
136
+ entry.update(attributes)
137
+ connection.strategy.add_entry(DN, entry)
138
+ with patch.object(watchdog, 'Server'), patch.object(watchdog, 'Connection', return_value=connection):
139
+ return watchdog.retrieve_ldap()
140
+
141
+ def test_absent_requested_attribute_is_empty(self):
142
+ config.SEARCH_ATTRIBUTE = ['cn', 'mail', 'entryUUID']
143
+ self.assertEqual(self.retrieve()[UUID]['mail'], [])
144
+
145
+ def test_uuid_is_retrieved_with_custom_attribute_selection(self):
146
+ config.SEARCH_ATTRIBUTE = ['cn']
147
+ self.assertEqual(self.retrieve()[UUID]['entryUUID'], [UUID])
148
+ self.assertEqual(config.SEARCH_ATTRIBUTE, ['cn'])
149
+
150
+ def test_missing_uuid_rejects_the_snapshot(self):
151
+ with self.assertRaisesRegex(LDAPException, 'no readable entryUUID'):
152
+ self.retrieve(entryUUID=[])
153
+
154
+ def test_all_binary_values_are_preserved(self):
155
+ entry = self.retrieve(jpegPhoto=[b'\xff\x00', b'\x03\xfc'])[UUID]
156
+ self.assertEqual(entry['jpegPhoto'], ['/wA=', 'A/w='])
157
+
158
+ def test_binary_changes_compare_whole_values(self):
159
+ old = self.retrieve(jpegPhoto=[b'\xff\x00'])
160
+ new = self.retrieve(jpegPhoto=[b'\x03\xfc'])
161
+ with patch.object(watchdog, 'announce') as announce:
162
+ watchdog.compare_ldap_entries(old, new)
163
+ announce.assert_called_once()
164
+ self.assertEqual(announce.call_args[0][2]['modifications'], [{'jpegPhoto': ('/wA=', 'A/w=')}])
165
+
166
+
167
+ class SnapshotTests(unittest.TestCase):
168
+ def setUp(self):
169
+ self.connection = Mock()
170
+ self.connection.bind.return_value = True
171
+ self.connection.result = {'result': 0}
172
+ self.connection.entries = []
173
+ for patcher in (
174
+ patch.object(watchdog, 'Server'),
175
+ patch.object(watchdog, 'Connection', return_value=self.connection),
176
+ ):
177
+ patcher.start()
178
+ self.addCleanup(patcher.stop)
179
+
180
+ def test_failed_bind_is_not_an_empty_snapshot(self):
181
+ self.connection.bind.return_value = False
182
+ self.connection.result = {'result': 49, 'description': 'invalidCredentials'}
183
+ with self.assertRaisesRegex(LDAPException, 'bind failed'):
184
+ watchdog.retrieve_ldap()
185
+ self.connection.unbind.assert_called_once()
186
+
187
+ def test_successful_empty_search_is_a_valid_snapshot(self):
188
+ self.connection.search.return_value = False
189
+ self.assertEqual(watchdog.retrieve_ldap(), {})
190
+ self.connection.unbind.assert_called_once()
191
+
192
+ def set_page(self, uuid, cookie, result=0):
193
+ self.connection.entries = [Mock(entry_to_json=Mock(return_value=json.dumps({
194
+ 'dn': DN, 'attributes': {'entryUUID': [uuid]},
195
+ })))]
196
+ self.connection.result = {
197
+ 'result': result,
198
+ 'controls': {'1.2.840.113556.1.4.319': {'value': {'cookie': cookie}}},
199
+ }
200
+
201
+ def test_all_pages_are_retrieved(self):
202
+ pages = iter([(UUID, b'next'), ('second', b'')])
203
+ self.connection.search.side_effect = lambda **kwargs: self.set_page(*next(pages))
204
+ self.assertEqual(set(watchdog.retrieve_ldap()), {UUID, 'second'})
205
+ calls = self.connection.search.call_args_list
206
+ self.assertEqual([call[1]['paged_cookie'] for call in calls], [None, b'next'])
207
+ self.connection.unbind.assert_called_once()
208
+
209
+ def test_later_page_failure_rejects_the_entire_snapshot(self):
210
+ pages = iter([(UUID, b'next', 0), ('second', b'', 4)])
211
+ self.connection.search.side_effect = lambda **kwargs: self.set_page(*next(pages))
212
+ with self.assertRaisesRegex(LDAPException, 'search failed'):
213
+ watchdog.retrieve_ldap()
214
+ self.connection.unbind.assert_called_once()
215
+
216
+ def test_failed_poll_preserves_the_last_successful_snapshot(self):
217
+ old = snapshot(title=['old'])
218
+ new = snapshot(title=['new'])
219
+ for error in (LDAPException('search failed'), LDAPSocketReceiveError('disconnected')):
220
+ with self.subTest(error=type(error).__name__):
221
+ with patch.object(watchdog, 'retrieve_ldap', side_effect=[old, error, new]), \
222
+ patch.object(watchdog.time, 'sleep', side_effect=[None, None, KeyboardInterrupt]), \
223
+ patch.object(watchdog, 'compare_ldap_entries') as compare, \
224
+ redirect_stderr(io.StringIO()):
225
+ with self.assertRaises(KeyboardInterrupt):
226
+ watchdog.main()
227
+ compare.assert_called_once_with(old, new)
228
+
229
+
230
+ class TLSTests(unittest.TestCase):
231
+ def test_ldaps_requires_a_trusted_certificate(self):
232
+ connection = Mock()
233
+ connection.bind.return_value = False
234
+ with patch.multiple(config, LDAP_SERVER='ldaps://localhost', LDAP_CA_CERTS_FILE=None), \
235
+ patch.object(watchdog, 'Connection', return_value=connection) as factory:
236
+ with self.assertRaises(LDAPException):
237
+ watchdog.retrieve_ldap()
238
+ server = factory.call_args[0][0]
239
+ self.assertTrue(server.ssl)
240
+ self.assertEqual(server.tls.validate, ssl.CERT_REQUIRED)
241
+
242
+ def requests_received(self, certificate, host, ca_certs_file, verify=True):
243
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
244
+ context.load_cert_chain(os.path.join(CERTS, certificate))
245
+ server_names, received = [], []
246
+ context.sni_callback = lambda tls, server_name, _: server_names.append(server_name)
247
+ listener = socket.socket()
248
+ listener.settimeout(5)
249
+ listener.bind(('127.0.0.1', 0))
250
+ listener.listen(1)
251
+
252
+ def serve():
253
+ with listener:
254
+ try:
255
+ with listener.accept()[0] as client, context.wrap_socket(client, server_side=True) as tls:
256
+ if tls.recv(1):
257
+ received.append(server_names[-1])
258
+ except OSError:
259
+ pass
260
+
261
+ server = threading.Thread(target=serve)
262
+ server.start()
263
+ with patch.multiple(config, LDAP_SERVER=f'ldaps://{host}:{listener.getsockname()[1]}',
264
+ LDAP_CA_CERTS_FILE=ca_certs_file and os.path.join(CERTS, ca_certs_file),
265
+ LDAP_TLS_VERIFY=verify, LDAP_USERNAME='', LDAP_PASSWORD='', NETWORK_TIMEOUT=5):
266
+ with self.assertRaises(LDAPException):
267
+ watchdog.retrieve_ldap()
268
+ server.join()
269
+ return received
270
+
271
+ def test_certificate_is_verified_for_dns_names_and_ip_addresses(self):
272
+ for host, server_name in (('localhost', 'localhost'), ('127.0.0.1', None)):
273
+ with self.subTest(host=host):
274
+ self.assertEqual(self.requests_received('localhost.pem', host, 'localhost.pem'), [server_name])
275
+
276
+ def test_mismatched_or_untrusted_certificates_are_rejected(self):
277
+ for certificate, host, ca_certs_file in (
278
+ ('ldap.example.com.pem', 'localhost', 'ldap.example.com.pem'),
279
+ ('ldap.example.com.pem', '127.0.0.1', 'ldap.example.com.pem'),
280
+ ('localhost.pem', 'localhost', None),
281
+ ):
282
+ with self.subTest(certificate=certificate, host=host, ca_certs_file=ca_certs_file):
283
+ self.assertEqual(self.requests_received(certificate, host, ca_certs_file), [])
284
+
285
+ def test_verification_can_be_turned_off(self):
286
+ for certificate, host in (('ldap.example.com.pem', 'localhost'), ('localhost.pem', '127.0.0.1')):
287
+ with self.subTest(certificate=certificate, host=host):
288
+ self.assertEqual(len(self.requests_received(certificate, host, None, verify=False)), 1)
289
+
290
+
291
+ class NetworkTests(unittest.TestCase):
292
+ def test_ldap_waits_are_bounded_for_authenticated_and_anonymous_binds(self):
293
+ for username, password in (('', ''), ('alice', 'password')):
294
+ with self.subTest(authenticated=bool(username)):
295
+ connection = Mock()
296
+ connection.bind.return_value = False
297
+ with patch.multiple(config, LDAP_SERVER='localhost', LDAP_CA_CERTS_FILE=None,
298
+ LDAP_USERNAME=username, LDAP_PASSWORD=password, NETWORK_TIMEOUT=7), \
299
+ patch.object(watchdog, 'Connection', return_value=connection) as factory:
300
+ with self.assertRaises(LDAPException):
301
+ watchdog.retrieve_ldap()
302
+ self.assertEqual(factory.call_args[0][0].connect_timeout, 7)
303
+ self.assertEqual(factory.call_args[1]['receive_timeout'], 7)
304
+
305
+ def test_slack_failures_are_reported_without_losing_console_output(self):
306
+ response = requests.Response()
307
+ response.status_code = 429
308
+ response.url = 'https://example.invalid/secret-webhook'
309
+ for failure in (response, requests.ConnectionError('secret-webhook')):
310
+ with self.subTest(failure=type(failure).__name__):
311
+ output, errors = io.StringIO(), io.StringIO()
312
+ with patch.multiple(config, SLACK_WEBHOOK=response.url, NETWORK_TIMEOUT=7), \
313
+ patch.object(watchdog, 'requests', requests, create=True), \
314
+ patch.object(requests, 'post') as post, \
315
+ redirect_stdout(output), redirect_stderr(errors):
316
+ if isinstance(failure, Exception):
317
+ post.side_effect = failure
318
+ else:
319
+ post.return_value = failure
320
+ watchdog.announce(DN, 'add', {'cn': ['Alice']})
321
+ self.assertIn('Alice', output.getvalue())
322
+ self.assertIn('Slack notification failed', errors.getvalue())
323
+ self.assertIn('ConnectionError' if isinstance(failure, Exception) else 'HTTP 429', errors.getvalue())
324
+ self.assertNotIn('secret-webhook', errors.getvalue())
325
+ self.assertEqual(post.call_args[1]['timeout'], 7)
@@ -1,4 +0,0 @@
1
- ldap3
2
-
3
- [slack]
4
- requests
File without changes
File without changes
File without changes