Skip to content

Commit 80f2906

Browse files
committed
Bump version to 1.2.5 and add OU filtering functionality in BloodHound CLI
This commit updates the version to 1.2.5 in the project metadata and introduces a new feature that allows users to filter by Organizational Unit (OU) when querying users. The implementation includes updates to the command parser and core client methods, along with corresponding unit tests to ensure expected behavior.
1 parent 1be8c8a commit 80f2906

6 files changed

Lines changed: 148 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "bloodhound-cli" # The package name as installed via pip (pip install bloodhound-cli)
7-
version = "1.2.4" # Bump: add CE skeleton, --edition, --verbose, rich optional
7+
version = "1.2.5" # Bump: add CE skeleton, --edition, --verbose, rich optional
88
description = "CLI for querying BloodHound data (Legacy Neo4j + CE skeleton)."
99
readme = "README.md"
1010
authors = [

src/bloodhound_cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""BloodHound CLI package entrypoint."""
22

3-
__version__ = "1.2.4"
3+
__version__ = "1.2.5"
44
__all__ = ["__version__"]
55

66
# End of package metadata

src/bloodhound_cli/core/ce.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,45 @@ def get_users(self, domain: str) -> List[str]:
198198
users.append(samaccountname)
199199

200200
return users
201+
except Exception:
202+
return []
203+
204+
def get_users_in_ou(self, domain: str, ou_distinguished_name: str) -> List[str]:
205+
"""Get enabled users that belong to a specific OU using its distinguished name.
206+
207+
Args:
208+
domain: AD domain name to filter users by (e.g. "north.sevenkingdoms.local").
209+
ou_distinguished_name: Distinguished Name (DN) of the OU to search under.
210+
211+
Returns:
212+
List of `samaccountname` values for users that belong to the OU.
213+
"""
214+
try:
215+
# Escape single quotes to avoid breaking the Cypher string
216+
sanitized_ou_dn = ou_distinguished_name.replace("'", "\\'")
217+
218+
cypher_query = f"""
219+
MATCH (ou:OU)
220+
WHERE toLower(ou.distinguishedname) = toLower('{sanitized_ou_dn}')
221+
MATCH (u:User)
222+
WHERE u.enabled = true
223+
AND toUpper(u.domain) = '{domain.upper()}'
224+
AND toLower(u.distinguishedname) CONTAINS toLower(ou.distinguishedname)
225+
RETURN u
226+
"""
227+
228+
result = self.execute_query(cypher_query)
229+
users: List[str] = []
201230

231+
if result and isinstance(result, list):
232+
for node_properties in result:
233+
samaccountname = node_properties.get("samaccountname") or node_properties.get("name", "")
234+
if samaccountname:
235+
if "@" in samaccountname:
236+
samaccountname = samaccountname.split("@")[0]
237+
users.append(samaccountname)
238+
239+
return users
202240
except Exception:
203241
return []
204242

src/bloodhound_cli/main.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,15 @@ def cmd_users(args):
156156
output_results(results, args.output, args.verbose, "password info")
157157
return
158158

159-
if args.high_value:
159+
if getattr(args, "ou_dn", None):
160+
if args.edition.lower() != "ce":
161+
print(
162+
"Filtering users by OU is only available for BloodHound CE (--edition ce)."
163+
)
164+
return
165+
users = client.get_users_in_ou(args.domain, args.ou_dn)
166+
user_type = f"users in OU {args.ou_dn}"
167+
elif args.high_value:
160168
users = client.get_highvalue_users(args.domain)
161169
user_type = "high value users"
162170
elif args.admin_count:
@@ -704,6 +712,11 @@ def main():
704712
users_parser.add_argument(
705713
"-u", "--user", help="Specific user to query (for password-last-change)"
706714
)
715+
users_parser.add_argument(
716+
"--ou-dn",
717+
dest="ou_dn",
718+
help="Distinguished Name of an OU to list its users (CE only)",
719+
)
707720
users_parser.add_argument(
708721
"--high-value", action="store_true", help="Show only high value users"
709722
)

tests/unit/test_ce_queries.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,36 @@ def test_get_users_exception_handling(self, mock_ce_client):
309309
users = mock_ce_client.get_users("error.domain.local")
310310

311311
assert len(users) == 0
312+
313+
def test_get_users_in_ou_exception_handling(self, mock_ce_client):
314+
"""Test exception handling in get_users_in_ou"""
315+
mock_ce_client.execute_query.side_effect = Exception("Query error")
316+
317+
users = mock_ce_client.get_users_in_ou(
318+
"error.domain.local",
319+
"OU=Winterfell,DC=north,DC=sevenkingdoms,DC=local",
320+
)
321+
322+
assert len(users) == 0
323+
324+
def test_get_users_in_ou_uses_ou_dn_and_domain(self, mock_ce_client):
325+
"""Test OU-based user enumeration builds the correct Cypher query and parses results."""
326+
ou_dn = "OU=Winterfell,DC=north,DC=sevenkingdoms,DC=local"
327+
domain = "north.sevenkingdoms.local"
328+
329+
users = mock_ce_client.get_users_in_ou(domain, ou_dn)
330+
331+
# Same mocked execute_query data as for get_users
332+
assert len(users) == 13
333+
assert "jeor.mormont" in users
334+
335+
mock_ce_client.execute_query.assert_called_once()
336+
query = mock_ce_client.execute_query.call_args[0][0]
337+
assert "MATCH (ou:OU)" in query
338+
assert "MATCH (u:User)" in query
339+
assert "ou.distinguishedname" in query
340+
assert domain.upper() in query
341+
assert ou_dn in query
312342

313343
def test_get_computers_basic(self, mock_ce_client_computers):
314344
"""Test basic computer enumeration with CySQL query"""
@@ -736,6 +766,69 @@ def close(self):
736766
assert dummy_client.calls == [("essos.local", "daenerys.targaryen", True)]
737767
assert dummy_client.closed is True
738768

769+
def test_user_command_ou_filter_outputs_expected_users(self, monkeypatch, capsys):
770+
"""Simulate `bloodhound-cli user --ou-dn ... -d sevenkingdoms.local` output."""
771+
expected_users = [
772+
"maester.pycelle",
773+
"lord.varys",
774+
"petyer.baelish",
775+
"stannis.baratheon",
776+
"joffrey.baratheon",
777+
"renly.baratheon",
778+
"robert.baratheon",
779+
"cersei.lannister",
780+
"jaime.lannister",
781+
"tywin.lannister",
782+
]
783+
784+
class DummyClient:
785+
def __init__(self):
786+
self.closed = False
787+
self.calls = []
788+
789+
def get_users_in_ou(self, domain, ou_distinguished_name):
790+
self.calls.append((domain, ou_distinguished_name))
791+
return expected_users
792+
793+
def close(self):
794+
self.closed = True
795+
796+
dummy_client = DummyClient()
797+
monkeypatch.setattr(
798+
cli_main,
799+
"get_client",
800+
lambda *_, **__: dummy_client,
801+
)
802+
803+
args = SimpleNamespace(
804+
edition="ce",
805+
uri=None,
806+
user=None,
807+
password=None,
808+
base_url="http://localhost:8080",
809+
username="admin",
810+
ce_password="Bloodhound123!",
811+
debug=False,
812+
verbose=False,
813+
domain="sevenkingdoms.local",
814+
ou_dn="OU=crownlands,DC=sevenkingdoms,DC=local",
815+
high_value=False,
816+
admin_count=False,
817+
password_never_expires=False,
818+
password_not_required=False,
819+
password_last_change=False,
820+
output=None,
821+
)
822+
823+
cli_main.cmd_users(args)
824+
825+
captured = capsys.readouterr()
826+
assert captured.out.strip().splitlines() == expected_users
827+
assert dummy_client.calls == [
828+
("sevenkingdoms.local", "OU=crownlands,DC=sevenkingdoms,DC=local")
829+
]
830+
assert dummy_client.closed is True
831+
739832
def test_version_command_prints_version(self, capsys):
740833
"""Ensure `bloodhound-cli version` prints the version string."""
741834
cli_main.cmd_version(SimpleNamespace())

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)