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.
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/LDAP_Monitor.egg-info/PKG-INFO +40 -18
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/LDAP_Monitor.egg-info/SOURCES.txt +2 -1
- ldap_monitor-2.0.0/LDAP_Monitor.egg-info/requires.txt +5 -0
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/PKG-INFO +40 -18
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/README.md +36 -12
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/ldap_watchdog/__init__.py +1 -1
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/ldap_watchdog/config.py +5 -0
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/ldap_watchdog/watchdog.py +95 -38
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/setup.py +3 -5
- ldap_monitor-2.0.0/tests/test_watchdog.py +325 -0
- ldap_monitor-1.0.0/LDAP_Monitor.egg-info/requires.txt +0 -4
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/LDAP_Monitor.egg-info/dependency_links.txt +0 -0
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/LDAP_Monitor.egg-info/entry_points.txt +0 -0
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/LDAP_Monitor.egg-info/top_level.txt +0 -0
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/LICENSE +0 -0
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/MANIFEST.in +0 -0
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/ldap_watchdog/__main__.py +0 -0
- {ldap_monitor-1.0.0 → ldap_monitor-2.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: LDAP-Monitor
|
|
3
|
-
Version:
|
|
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.
|
|
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
|
|
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
|
-

|
|
71
|
+

|
|
74
72
|
|
|
75
73
|
### Slack output:
|
|
76
74
|
|
|
77
|
-

|
|
75
|
+

|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
227
|
-
| `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes
|
|
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).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: LDAP-Monitor
|
|
3
|
-
Version:
|
|
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.
|
|
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
|
|
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
|
-

|
|
71
|
+

|
|
74
72
|
|
|
75
73
|
### Slack output:
|
|
76
74
|
|
|
77
|
-

|
|
75
|
+

|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
227
|
-
| `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes
|
|
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
|
|
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
|
-

|
|
31
|
+

|
|
32
32
|
|
|
33
33
|
### Slack output:
|
|
34
34
|
|
|
35
|
-

|
|
35
|
+

|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
185
|
-
| `LDAP_WATCHDOG_IGNORED_ATTRIBUTES` | Comma-separated list of attributes
|
|
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__ = "
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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]
|
|
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
|
|
334
|
-
print(f"LDAP
|
|
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.
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|