DACLhound 0.1.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.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: DACLhound
3
+ Version: 0.1.0
4
+ Summary: Collect Active Directory DACLs and explore them in an interactive graph
5
+ License-Expression: MIT
6
+ Keywords: active-directory,acl,dacl,bloodhound,security
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Information Technology
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Security
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: bloodyAD==2.5.5
20
+ Dynamic: license-file
21
+
22
+ # DACLhound
23
+
24
+ DACLhound collects Active Directory security descriptors over LDAP and turns the results into a graph you can inspect in a browser. The collector uses bloodyAD as a Python library; it does not run the `bloodyAD` command.
25
+
26
+ ## Install
27
+
28
+ Python 3.10 or newer is required.
29
+
30
+ ```sh
31
+ pipx install DACLhound
32
+ ```
33
+
34
+ Upgrade later with:
35
+
36
+ ```sh
37
+ pipx upgrade DACLhound
38
+ ```
39
+
40
+ ## Collect a graph
41
+
42
+ ```sh
43
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --dc-ip "$IP" --output "$DOMAIN.json"
44
+ ```
45
+
46
+ The collector is read-only. By default it collects users, groups, computers, OUs, containers, GPOs, the domain, and ADCS data. Use `--types` to limit directory object types:
47
+
48
+ ```sh
49
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --types users,groups --output users-and-groups.json
50
+ ```
51
+
52
+ Use `--no-adcs` to skip ADCS collection. The normalized graph is written to `--output`. Add `--raw-output` to keep the bloodyAD response as well.
53
+
54
+ ## Authentication
55
+
56
+ ### NTLM password
57
+
58
+ ```sh
59
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --dc-ip "$IP" -o "$DOMAIN.json"
60
+ ```
61
+
62
+ ### NTLM pass-the-hash
63
+
64
+ Use `LMHASH:NTHASH`; if you only have the NT hash, leave the LM field empty:
65
+
66
+ ```sh
67
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -hashes ':<NTHASH>' --dc-ip "$IP" -o "$DOMAIN.json"
68
+ ```
69
+
70
+ ### Kerberos
71
+
72
+ Use the current `KRB5CCNAME`, or pass a ccache path to `-k`:
73
+
74
+ ```sh
75
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -k --dc-ip "$IP" -o "$DOMAIN.json"
76
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -k user.ccache --dc-ip "$IP" -o "$DOMAIN.json"
77
+ ```
78
+
79
+ Kerberos can also use a password or NT hash with `-k`. For an AES key, use `-aesKey <AESKEY>`. Add `-s` to use LDAPS.
80
+
81
+ ## Open the graph
82
+
83
+ Run the viewer in the foreground and stop it with `Ctrl+C`:
84
+
85
+ ```sh
86
+ DACLhound.py graph --input "$DOMAIN.json"
87
+ ```
88
+
89
+ Or run it in the background:
90
+
91
+ ```sh
92
+ DACLhound.py graph start --input "$DOMAIN.json"
93
+ DACLhound.py graph status
94
+ DACLhound.py graph stop
95
+ ```
96
+
97
+ The server listens on `127.0.0.1:8765` by default. Uploaded JSON files stay in memory while the server is running and are discarded when it stops. Use `--bind` and `--port` to change the listener. Run `DACLhound.py collect --help` or `DACLhound.py graph --help` for all options.
98
+
99
+ ## License
100
+
101
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ daclgraph_server.py
4
+ daclhound_cli.py
5
+ pyproject.toml
6
+ DACLhound.egg-info/PKG-INFO
7
+ DACLhound.egg-info/SOURCES.txt
8
+ DACLhound.egg-info/dependency_links.txt
9
+ DACLhound.egg-info/entry_points.txt
10
+ DACLhound.egg-info/requires.txt
11
+ DACLhound.egg-info/top_level.txt
12
+ daclhound_assets/__init__.py
13
+ daclhound_assets/viewer.html
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ DACLhound.py = daclhound_cli:entrypoint
3
+ daclhound = daclhound_cli:entrypoint
@@ -0,0 +1 @@
1
+ bloodyAD==2.5.5
@@ -0,0 +1,3 @@
1
+ daclgraph_server
2
+ daclhound_assets
3
+ daclhound_cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gzzcoo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,101 @@
1
+ Metadata-Version: 2.4
2
+ Name: DACLhound
3
+ Version: 0.1.0
4
+ Summary: Collect Active Directory DACLs and explore them in an interactive graph
5
+ License-Expression: MIT
6
+ Keywords: active-directory,acl,dacl,bloodhound,security
7
+ Classifier: Development Status :: 3 - Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Information Technology
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Topic :: Security
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: bloodyAD==2.5.5
20
+ Dynamic: license-file
21
+
22
+ # DACLhound
23
+
24
+ DACLhound collects Active Directory security descriptors over LDAP and turns the results into a graph you can inspect in a browser. The collector uses bloodyAD as a Python library; it does not run the `bloodyAD` command.
25
+
26
+ ## Install
27
+
28
+ Python 3.10 or newer is required.
29
+
30
+ ```sh
31
+ pipx install DACLhound
32
+ ```
33
+
34
+ Upgrade later with:
35
+
36
+ ```sh
37
+ pipx upgrade DACLhound
38
+ ```
39
+
40
+ ## Collect a graph
41
+
42
+ ```sh
43
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --dc-ip "$IP" --output "$DOMAIN.json"
44
+ ```
45
+
46
+ The collector is read-only. By default it collects users, groups, computers, OUs, containers, GPOs, the domain, and ADCS data. Use `--types` to limit directory object types:
47
+
48
+ ```sh
49
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --types users,groups --output users-and-groups.json
50
+ ```
51
+
52
+ Use `--no-adcs` to skip ADCS collection. The normalized graph is written to `--output`. Add `--raw-output` to keep the bloodyAD response as well.
53
+
54
+ ## Authentication
55
+
56
+ ### NTLM password
57
+
58
+ ```sh
59
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --dc-ip "$IP" -o "$DOMAIN.json"
60
+ ```
61
+
62
+ ### NTLM pass-the-hash
63
+
64
+ Use `LMHASH:NTHASH`; if you only have the NT hash, leave the LM field empty:
65
+
66
+ ```sh
67
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -hashes ':<NTHASH>' --dc-ip "$IP" -o "$DOMAIN.json"
68
+ ```
69
+
70
+ ### Kerberos
71
+
72
+ Use the current `KRB5CCNAME`, or pass a ccache path to `-k`:
73
+
74
+ ```sh
75
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -k --dc-ip "$IP" -o "$DOMAIN.json"
76
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -k user.ccache --dc-ip "$IP" -o "$DOMAIN.json"
77
+ ```
78
+
79
+ Kerberos can also use a password or NT hash with `-k`. For an AES key, use `-aesKey <AESKEY>`. Add `-s` to use LDAPS.
80
+
81
+ ## Open the graph
82
+
83
+ Run the viewer in the foreground and stop it with `Ctrl+C`:
84
+
85
+ ```sh
86
+ DACLhound.py graph --input "$DOMAIN.json"
87
+ ```
88
+
89
+ Or run it in the background:
90
+
91
+ ```sh
92
+ DACLhound.py graph start --input "$DOMAIN.json"
93
+ DACLhound.py graph status
94
+ DACLhound.py graph stop
95
+ ```
96
+
97
+ The server listens on `127.0.0.1:8765` by default. Uploaded JSON files stay in memory while the server is running and are discarded when it stops. Use `--bind` and `--port` to change the listener. Run `DACLhound.py collect --help` or `DACLhound.py graph --help` for all options.
98
+
99
+ ## License
100
+
101
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,80 @@
1
+ # DACLhound
2
+
3
+ DACLhound collects Active Directory security descriptors over LDAP and turns the results into a graph you can inspect in a browser. The collector uses bloodyAD as a Python library; it does not run the `bloodyAD` command.
4
+
5
+ ## Install
6
+
7
+ Python 3.10 or newer is required.
8
+
9
+ ```sh
10
+ pipx install DACLhound
11
+ ```
12
+
13
+ Upgrade later with:
14
+
15
+ ```sh
16
+ pipx upgrade DACLhound
17
+ ```
18
+
19
+ ## Collect a graph
20
+
21
+ ```sh
22
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --dc-ip "$IP" --output "$DOMAIN.json"
23
+ ```
24
+
25
+ The collector is read-only. By default it collects users, groups, computers, OUs, containers, GPOs, the domain, and ADCS data. Use `--types` to limit directory object types:
26
+
27
+ ```sh
28
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --types users,groups --output users-and-groups.json
29
+ ```
30
+
31
+ Use `--no-adcs` to skip ADCS collection. The normalized graph is written to `--output`. Add `--raw-output` to keep the bloodyAD response as well.
32
+
33
+ ## Authentication
34
+
35
+ ### NTLM password
36
+
37
+ ```sh
38
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -p 'password' --dc-ip "$IP" -o "$DOMAIN.json"
39
+ ```
40
+
41
+ ### NTLM pass-the-hash
42
+
43
+ Use `LMHASH:NTHASH`; if you only have the NT hash, leave the LM field empty:
44
+
45
+ ```sh
46
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -hashes ':<NTHASH>' --dc-ip "$IP" -o "$DOMAIN.json"
47
+ ```
48
+
49
+ ### Kerberos
50
+
51
+ Use the current `KRB5CCNAME`, or pass a ccache path to `-k`:
52
+
53
+ ```sh
54
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -k --dc-ip "$IP" -o "$DOMAIN.json"
55
+ DACLhound.py collect --host "$FQDN" -d "$DOMAIN" -u 'user' -k user.ccache --dc-ip "$IP" -o "$DOMAIN.json"
56
+ ```
57
+
58
+ Kerberos can also use a password or NT hash with `-k`. For an AES key, use `-aesKey <AESKEY>`. Add `-s` to use LDAPS.
59
+
60
+ ## Open the graph
61
+
62
+ Run the viewer in the foreground and stop it with `Ctrl+C`:
63
+
64
+ ```sh
65
+ DACLhound.py graph --input "$DOMAIN.json"
66
+ ```
67
+
68
+ Or run it in the background:
69
+
70
+ ```sh
71
+ DACLhound.py graph start --input "$DOMAIN.json"
72
+ DACLhound.py graph status
73
+ DACLhound.py graph stop
74
+ ```
75
+
76
+ The server listens on `127.0.0.1:8765` by default. Uploaded JSON files stay in memory while the server is running and are discarded when it stops. Use `--bind` and `--port` to change the listener. Run `DACLhound.py collect --help` or `DACLhound.py graph --help` for all options.
77
+
78
+ ## License
79
+
80
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,150 @@
1
+ """Serve the DACL graph viewer and keep uploaded datasets in process memory."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from importlib.resources import files
6
+ import json
7
+ import threading
8
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
+ from pathlib import Path
10
+ from urllib.parse import unquote, urlparse
11
+
12
+ VIEWER = files("daclhound_assets").joinpath("viewer.html")
13
+ MAX_BODY = 64 * 1024 * 1024
14
+
15
+
16
+ class DatasetStore:
17
+ """Thread-safe, process-local storage for uploaded graph datasets."""
18
+
19
+ def __init__(self, initial_json: str | None = None) -> None:
20
+ self._lock = threading.Lock()
21
+ self._next_id = 1
22
+ self._datasets: list[dict] = []
23
+ if initial_json:
24
+ path = Path(initial_json).expanduser().resolve()
25
+ self.add(path.name, json.loads(path.read_text(encoding="utf-8")))
26
+
27
+ def add(self, name: str, data: object) -> dict:
28
+ if not isinstance(data, dict) or not isinstance(data.get("nodes"), list) or not isinstance(data.get("edges"), list):
29
+ raise ValueError("Expected a DACL graph JSON object with nodes and edges arrays")
30
+ with self._lock:
31
+ key = str(self._next_id)
32
+ self._next_id += 1
33
+ item = {"key": key, "name": name or "daclgraph.json", "data": data}
34
+ self._datasets.append(item)
35
+ return item
36
+
37
+ def list(self) -> list[dict]:
38
+ with self._lock:
39
+ return list(self._datasets)
40
+
41
+ def remove(self, key: str) -> bool:
42
+ with self._lock:
43
+ remaining = [item for item in self._datasets if item["key"] != key]
44
+ if len(remaining) == len(self._datasets):
45
+ return False
46
+ self._datasets = remaining
47
+ return True
48
+
49
+
50
+ def make_handler(store: DatasetStore, log_requests: bool = False):
51
+ class Handler(BaseHTTPRequestHandler):
52
+ server_version = "DACLGraph/1.0"
53
+
54
+ def log_message(self, fmt: str, *args) -> None:
55
+ if log_requests:
56
+ print(f"[{self.log_date_time_string()}] {fmt % args}")
57
+
58
+ def send_bytes(self, status: int, body: bytes, content_type: str) -> None:
59
+ self.send_response(status)
60
+ self.send_header("Content-Type", content_type)
61
+ self.send_header("Content-Length", str(len(body)))
62
+ self.send_header("Cache-Control", "no-store")
63
+ self.end_headers()
64
+ self.wfile.write(body)
65
+
66
+ def send_json(self, status: int, value: object) -> None:
67
+ body = json.dumps(value, ensure_ascii=False).encode("utf-8")
68
+ self.send_bytes(status, body, "application/json; charset=utf-8")
69
+
70
+ def do_GET(self) -> None:
71
+ path = urlparse(self.path).path
72
+ if path == "/api/datasets":
73
+ self.send_json(200, store.list())
74
+ return
75
+ if path in ("/", "/daclgraph_viewer.html"):
76
+ try:
77
+ self.send_bytes(200, VIEWER.read_bytes(), "text/html; charset=utf-8")
78
+ except OSError as exc:
79
+ self.send_json(500, {"error": str(exc)})
80
+ return
81
+ self.send_json(404, {"error": "Not found"})
82
+
83
+ def do_POST(self) -> None:
84
+ path = urlparse(self.path).path
85
+ if path != "/api/datasets":
86
+ self.send_json(404, {"error": "Not found"})
87
+ return
88
+ try:
89
+ length = int(self.headers.get("Content-Length", "0"))
90
+ except ValueError:
91
+ length = 0
92
+ if length <= 0 or length > MAX_BODY:
93
+ self.send_json(400, {"error": "Request body must be between 1 byte and 64 MiB"})
94
+ return
95
+ try:
96
+ payload = json.loads(self.rfile.read(length).decode("utf-8"))
97
+ except (json.JSONDecodeError, UnicodeDecodeError):
98
+ self.send_json(400, {"error": "Invalid JSON request body"})
99
+ return
100
+ if not isinstance(payload, dict):
101
+ self.send_json(400, {"error": "Expected an object containing name and data"})
102
+ return
103
+ try:
104
+ item = store.add(str(payload.get("name") or "daclgraph.json"), payload.get("data"))
105
+ except ValueError as exc:
106
+ self.send_json(400, {"error": str(exc)})
107
+ return
108
+ self.send_json(201, item)
109
+
110
+ def do_DELETE(self) -> None:
111
+ path = urlparse(self.path).path
112
+ prefix = "/api/datasets/"
113
+ if not path.startswith(prefix):
114
+ self.send_json(404, {"error": "Not found"})
115
+ return
116
+ key = unquote(path[len(prefix):])
117
+ if not store.remove(key):
118
+ self.send_json(404, {"error": "Dataset not found"})
119
+ return
120
+ self.send_json(200, {"removed": key})
121
+
122
+ return Handler
123
+
124
+
125
+ def run_server(host: str = "127.0.0.1", port: int = 8765, initial_json: str | None = None, log_requests: bool = False) -> None:
126
+ store = DatasetStore(initial_json)
127
+ server = ThreadingHTTPServer((host, port), make_handler(store, log_requests))
128
+ print(f"[+] DACLhound server listening at http://{host}:{port}/")
129
+ print("[+] Uploaded datasets are kept in memory until this process exits")
130
+ try:
131
+ server.serve_forever()
132
+ except KeyboardInterrupt:
133
+ print("\n[*] Stopping DACLhound server")
134
+ finally:
135
+ server.server_close()
136
+
137
+
138
+ def main() -> int:
139
+ parser = argparse.ArgumentParser(description="Serve the interactive DACL graph viewer with in-memory datasets.")
140
+ parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
141
+ parser.add_argument("--port", type=int, default=8765, help="HTTP port (default: 8765)")
142
+ parser.add_argument("--initial-json", help="Load one graph JSON into the in-memory session on startup")
143
+ parser.add_argument("--verbose", action="store_true", help="Log each HTTP request")
144
+ args = parser.parse_args()
145
+ run_server(args.host, args.port, args.initial_json, args.verbose)
146
+ return 0
147
+
148
+
149
+ if __name__ == "__main__":
150
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Static resources shipped with DACLhound."""