55from __future__ import annotations
66
77import importlib .util
8+ import inspect
89import sys
910import types
1011from pathlib import Path
@@ -105,6 +106,7 @@ def _load_module(name: str, filename: str):
105106certipy_mod = _load_module ("certipy_tools" , "certipy.py" )
106107impacket_mod = _load_module ("impacket_tools" , "impacket.py" )
107108cracking_mod = _load_module ("cracking_tools" , "cracking.py" )
109+ bloodyad_mod = _load_module ("bloodyad_tools" , "bloodyad.py" )
108110
109111
110112# ===================================================================
@@ -599,10 +601,14 @@ async def test_backend_error_suggests_john_with_known_mode(self, tmp_path):
599601 cracker = cracking_mod .Cracking ()
600602
601603 async def fake_execute (cmd , ** kwargs ):
602- raise RuntimeError ("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" )
604+ raise RuntimeError (
605+ "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"
606+ )
603607
604608 with patch .object (cracking_mod , "execute" , side_effect = fake_execute ):
605- with pytest .raises (RuntimeError , match = r"Use john_the_ripper.*hash_format='nt'" ):
609+ with pytest .raises (
610+ RuntimeError , match = r"Use john_the_ripper.*hash_format='nt'"
611+ ):
606612 await cracker .hashcat (
607613 hashcat_mode = 1000 ,
608614 hash_file = str (hash_file ),
@@ -618,7 +624,9 @@ async def test_backend_error_chains_original_exception(self, tmp_path):
618624 wordlist .write_text ("password\n " )
619625
620626 cracker = cracking_mod .Cracking ()
621- original = RuntimeError ("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" )
627+ original = RuntimeError (
628+ "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"
629+ )
622630
623631 async def fake_execute (cmd , ** kwargs ):
624632 raise original
@@ -654,6 +662,117 @@ async def fake_execute(cmd, **kwargs):
654662 )
655663
656664
665+ # ===================================================================
666+ # BloodyAD: _build_connection_options (pass-the-hash auth)
667+ # ===================================================================
668+
669+
670+ class TestBuildConnectionOptions :
671+ """A hash is supplied to bloodyAD through the password field (-p)."""
672+
673+ def _make_bloodyad (self ):
674+ return bloodyad_mod .BloodyAD ()
675+
676+ def _base_kwargs (self ):
677+ return {"domain" : "domain.local" , "username" : "alice" , "host" : "dc01" }
678+
679+ def test_bare_nt_hash_uses_leading_colon_password (self ):
680+ bad = self ._make_bloodyad ()
681+ cmd = bad ._build_connection_options (
682+ ** self ._base_kwargs (),
683+ hashes = "aad3b435b51404eeaad3b435b51404ee" ,
684+ )
685+ assert "--hashes" not in cmd
686+ p_idx = cmd .index ("-p" )
687+ assert cmd [p_idx + 1 ] == ":aad3b435b51404eeaad3b435b51404ee"
688+
689+ def test_full_lm_nt_hash_passed_through_unchanged (self ):
690+ bad = self ._make_bloodyad ()
691+ full = "aad3b435b51404eeaad3b435b51404ee:" "31d6cfe0d16ae931b73c59d7e0c089c0"
692+ cmd = bad ._build_connection_options (** self ._base_kwargs (), hashes = full )
693+ assert "--hashes" not in cmd
694+ p_idx = cmd .index ("-p" )
695+ assert cmd [p_idx + 1 ] == full
696+
697+ def test_password_takes_precedence_over_hashes (self ):
698+ bad = self ._make_bloodyad ()
699+ cmd = bad ._build_connection_options (
700+ ** self ._base_kwargs (),
701+ password = "Summer2026!" ,
702+ hashes = "deadbeef" ,
703+ )
704+ assert "--hashes" not in cmd
705+ p_idx = cmd .index ("-p" )
706+ assert cmd [p_idx + 1 ] == "Summer2026!"
707+ # The hash must not also leak into the argv.
708+ assert "deadbeef" not in cmd
709+ assert ":deadbeef" not in cmd
710+
711+ def test_no_hashes_flag_ever_emitted (self ):
712+ bad = self ._make_bloodyad ()
713+ cmd = bad ._build_connection_options (** self ._base_kwargs (), hashes = "deadbeef" )
714+ assert "--hashes" not in cmd
715+
716+
717+ # Every public bloodyad_* tool method must forward its `hashes` argument into
718+ # the shared connection-options helper. Testing the helper alone would not
719+ # catch a method that dropped `hashes=hashes` on its own call, so this walks
720+ # the real tool surface.
721+ _BLOODYAD_TOOL_METHODS = sorted (
722+ name
723+ for name , member in inspect .getmembers (
724+ bloodyad_mod .BloodyAD , inspect .iscoroutinefunction
725+ )
726+ if name .startswith ("bloodyad_" )
727+ )
728+
729+ _CONNECTION_PARAMS = {"domain" , "username" , "host" }
730+ _NT_HASH = "aad3b435b51404eeaad3b435b51404ee"
731+
732+
733+ def _call_kwargs_for (method ) -> dict :
734+ """Minimal kwargs to invoke a tool method on the hash-auth path: every
735+ required parameter gets a placeholder, and `hashes` carries a bare NT hash
736+ while `password` is left unset so the hash branch is taken."""
737+ kwargs : dict = {}
738+ for name , param in inspect .signature (method ).parameters .items ():
739+ if name == "self" :
740+ continue
741+ if name == "hashes" :
742+ kwargs [name ] = _NT_HASH
743+ elif name in _CONNECTION_PARAMS :
744+ kwargs [name ] = f"test-{ name } "
745+ elif param .default is inspect .Parameter .empty :
746+ kwargs [name ] = "test-value"
747+ return kwargs
748+
749+
750+ class TestPublicMethodsForwardHashes :
751+ """The ticket's failure was a tool authenticating without its hash. Guard
752+ every public tool method, not just the shared helper."""
753+
754+ def test_at_least_one_tool_method_discovered (self ):
755+ # A rename/refactor that empties this list must fail loudly rather than
756+ # silently parametrizing over nothing and passing.
757+ assert len (_BLOODYAD_TOOL_METHODS ) >= 20
758+
759+ @pytest .mark .parametrize ("method_name" , _BLOODYAD_TOOL_METHODS )
760+ @pytest .mark .asyncio
761+ async def test_hash_reaches_argv (self , method_name ):
762+ bad = bloodyad_mod .BloodyAD ()
763+ bad .timeout = 30
764+ method = getattr (bad , method_name )
765+
766+ captured = AsyncMock (return_value = "ok" )
767+ with patch .object (bloodyad_mod , "execute" , captured ):
768+ await method (** _call_kwargs_for (method ))
769+
770+ cmd = captured .call_args .args [0 ]
771+ assert "--hashes" not in cmd
772+ p_idx = cmd .index ("-p" )
773+ assert cmd [p_idx + 1 ] == f":{ _NT_HASH } "
774+
775+
657776# ===================================================================
658777# Cracking: results survive hashcat's non-zero / time-boxed exits
659778# ===================================================================
@@ -789,7 +908,9 @@ async def test_no_backend_short_circuits_before_show(self, tmp_path):
789908 """A missing GPU backend raises the john hint and never reaches --show."""
790909 hash_file , wordlist = self ._write_inputs (tmp_path )
791910 fake_execute , calls = _split_execute (
792- RuntimeError ("Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" ),
911+ RuntimeError (
912+ "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR"
913+ ),
793914 "svc_sql:Password1" ,
794915 )
795916 with patch .object (cracking_mod , "execute" , side_effect = fake_execute ):
0 commit comments