From 67bddfefda369298d904560465cf2b712ce6f3ce Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Sun, 2 Aug 2026 19:36:44 -0400 Subject: [PATCH 1/3] fix(network-ops): surface actionable error when hashcat has no GPU backend On GPU-less VMs, hashcat exits with CL_PLATFORM_NOT_FOUND_KHR and the agent gets an opaque error. Catch this specific failure and raise with a clear message directing the agent to use john_the_ripper instead, including the correct hash_format for known modes. Co-Authored-By: Claude --- capabilities/network-ops/tools/cracking.py | 67 ++++++++++++++++------ 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/capabilities/network-ops/tools/cracking.py b/capabilities/network-ops/tools/cracking.py index 9d8d406..6275c49 100644 --- a/capabilities/network-ops/tools/cracking.py +++ b/capabilities/network-ops/tools/cracking.py @@ -9,6 +9,15 @@ g_hashcat_lock = asyncio.Lock() +HASHCAT_TO_JOHN: dict[int, str] = { + 1000: "nt", + 2100: "mscash2", + 13100: "krb5tgs", + 18200: "krb5asrep", +} + +_NO_BACKEND_MARKERS = ("CL_PLATFORM_NOT_FOUND_KHR", "No devices found/left") + class Cracking(Toolset): """ @@ -56,31 +65,51 @@ async def hashcat( raise FileNotFoundError(f"Hash file {hash_file_path} does not exist.") if not os.path.exists(wordlist_path): - raise FileNotFoundError(f"Wordlist file {wordlist_path} does not exist.") + raise FileNotFoundError( + f"Wordlist file {wordlist_path} does not exist." + ) logger.info( f"Cracking {hash_file_path} with mode {hashcat_mode} using wordlist {wordlist_path}" ) # Execute the cracking command - await execute( - [ - "hashcat", - "-m", - str(hashcat_mode), - "-a", - "0", - hash_file_path, - wordlist_path, - "--runtime", - str(max_time_minutes * 60), - "--force", - ], - timeout=(max_time_minutes * 60) + 30, - ) + try: + await execute( + [ + "hashcat", + "-m", + str(hashcat_mode), + "-a", + "0", + hash_file_path, + wordlist_path, + "--runtime", + str(max_time_minutes * 60), + "--force", + ], + timeout=(max_time_minutes * 60) + 30, + ) + except Exception as e: + msg = str(e) + if any(marker in msg for marker in _NO_BACKEND_MARKERS): + john_fmt = HASHCAT_TO_JOHN.get(hashcat_mode) + if john_fmt: + raise RuntimeError( + f"hashcat failed: no OpenCL/CUDA/HIP backend available. " + f"Use john_the_ripper instead with hash_format='{john_fmt}'." + ) from e + raise RuntimeError( + f"hashcat failed: no OpenCL/CUDA/HIP backend available. " + f"Use john_the_ripper instead (hashcat mode {hashcat_mode} " + f"has no automatic john format mapping — consult john documentation)." + ) from e + raise # Execute the --show command to get the cracked results - return await execute(["hashcat", "-m", str(hashcat_mode), hash_file_path, "--show"]) + return await execute( + ["hashcat", "-m", str(hashcat_mode), hash_file_path, "--show"] + ) @tool_method(catch=True, variants=["john", "all"]) async def john_the_ripper( @@ -138,4 +167,6 @@ async def john_the_ripper( ) # Execute the --show command to get the cracked results - return await execute(["john", "--show", f"--format={hash_format}", hash_file_path]) + return await execute( + ["john", "--show", f"--format={hash_format}", hash_file_path] + ) From 7d206d24017ac78f599a9279c4ce98cfc9f629e4 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Sun, 2 Aug 2026 19:50:30 -0400 Subject: [PATCH 2/3] fix(network-ops): add tests for hashcat no-backend detection, expose john under hashcat variant Add three tests covering backend-marker detection (known mode, unknown mode, non-backend error propagation). Register john_the_ripper under the hashcat variant so the fallback suggestion is actionable. Co-Authored-By: Claude --- .../network-ops/tests/test_tool_fixes.py | 77 +++++++++++++++++++ capabilities/network-ops/tools/cracking.py | 2 +- 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/capabilities/network-ops/tests/test_tool_fixes.py b/capabilities/network-ops/tests/test_tool_fixes.py index f280e4d..3f5f1f1 100644 --- a/capabilities/network-ops/tests/test_tool_fixes.py +++ b/capabilities/network-ops/tests/test_tool_fixes.py @@ -104,6 +104,7 @@ def _load_module(name: str, filename: str): nmap_mod = _load_module("nmap_tools", "nmap.py") certipy_mod = _load_module("certipy_tools", "certipy.py") impacket_mod = _load_module("impacket_tools", "impacket.py") +cracking_mod = _load_module("cracking_tools", "cracking.py") # =================================================================== @@ -577,3 +578,79 @@ async def mock_wait(): await impacket_mod._kill_relay(proc) mock_getpgid.assert_called_once_with(99999) mock_killpg.assert_called() + + +# =================================================================== +# Cracking: hashcat no-backend detection +# =================================================================== + + +class TestHashcatNoBackendDetection: + """hashcat should raise an actionable error when no GPU backend is available.""" + + @pytest.mark.asyncio + async def test_backend_error_suggests_john_with_known_mode(self, tmp_path): + """CL_PLATFORM_NOT_FOUND_KHR should suggest john_the_ripper with mapped format.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text("aad3b435b51404eeaad3b435b51404ee") + wordlist = tmp_path / "wordlist.txt" + wordlist.write_text("password\n") + + cracker = cracking_mod.Cracking() + + async def fake_execute(cmd, **kwargs): + raise RuntimeError( + "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" + ) + + with patch.object(cracking_mod, "execute", side_effect=fake_execute): + with pytest.raises( + RuntimeError, match=r"Use john_the_ripper.*hash_format='nt'" + ): + await cracker.hashcat( + hashcat_mode=1000, + hash_file=str(hash_file), + wordlist_path=str(wordlist), + ) + + @pytest.mark.asyncio + async def test_backend_error_suggests_john_with_unknown_mode(self, tmp_path): + """Unknown hashcat mode should still suggest john but note missing mapping.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text("somehash") + wordlist = tmp_path / "wordlist.txt" + wordlist.write_text("password\n") + + cracker = cracking_mod.Cracking() + + async def fake_execute(cmd, **kwargs): + raise RuntimeError("Command failed (255): No devices found/left") + + with patch.object(cracking_mod, "execute", side_effect=fake_execute): + with pytest.raises(RuntimeError, match=r"no automatic john format mapping"): + await cracker.hashcat( + hashcat_mode=99999, + hash_file=str(hash_file), + wordlist_path=str(wordlist), + ) + + @pytest.mark.asyncio + async def test_non_backend_error_propagates_unchanged(self, tmp_path): + """Errors unrelated to missing backends should propagate as-is.""" + hash_file = tmp_path / "hashes.txt" + hash_file.write_text("badhash") + wordlist = tmp_path / "wordlist.txt" + wordlist.write_text("password\n") + + cracker = cracking_mod.Cracking() + + async def fake_execute(cmd, **kwargs): + raise RuntimeError("Command failed (1): Hash format mismatch") + + with patch.object(cracking_mod, "execute", side_effect=fake_execute): + with pytest.raises(RuntimeError, match=r"Hash format mismatch"): + await cracker.hashcat( + hashcat_mode=1000, + hash_file=str(hash_file), + wordlist_path=str(wordlist), + ) diff --git a/capabilities/network-ops/tools/cracking.py b/capabilities/network-ops/tools/cracking.py index 6275c49..3132fef 100644 --- a/capabilities/network-ops/tools/cracking.py +++ b/capabilities/network-ops/tools/cracking.py @@ -111,7 +111,7 @@ async def hashcat( ["hashcat", "-m", str(hashcat_mode), hash_file_path, "--show"] ) - @tool_method(catch=True, variants=["john", "all"]) + @tool_method(catch=True, variants=["hashcat", "john", "all"]) async def john_the_ripper( self, hash_format: str, From 3219f54ae75a8307fd578ca9f5dfb66ee64f4937 Mon Sep 17 00:00:00 2001 From: mkultraWasHere Date: Sun, 2 Aug 2026 19:59:58 -0400 Subject: [PATCH 3/3] test(network-ops): replace low-value test with exception chain assertion Drop the unknown-mode test (just a dict miss) and add a test verifying the original hashcat error is preserved as __cause__ on the re-raised RuntimeError. Co-Authored-By: Claude --- .../network-ops/tests/test_tool_fixes.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/capabilities/network-ops/tests/test_tool_fixes.py b/capabilities/network-ops/tests/test_tool_fixes.py index 3f5f1f1..24fe376 100644 --- a/capabilities/network-ops/tests/test_tool_fixes.py +++ b/capabilities/network-ops/tests/test_tool_fixes.py @@ -614,25 +614,29 @@ async def fake_execute(cmd, **kwargs): ) @pytest.mark.asyncio - async def test_backend_error_suggests_john_with_unknown_mode(self, tmp_path): - """Unknown hashcat mode should still suggest john but note missing mapping.""" + async def test_backend_error_chains_original_exception(self, tmp_path): + """The original hashcat error should be preserved as __cause__.""" hash_file = tmp_path / "hashes.txt" - hash_file.write_text("somehash") + hash_file.write_text("aad3b435b51404eeaad3b435b51404ee") wordlist = tmp_path / "wordlist.txt" wordlist.write_text("password\n") cracker = cracking_mod.Cracking() + original = RuntimeError( + "Command failed (255): clGetPlatformIDs(): CL_PLATFORM_NOT_FOUND_KHR" + ) async def fake_execute(cmd, **kwargs): - raise RuntimeError("Command failed (255): No devices found/left") + raise original with patch.object(cracking_mod, "execute", side_effect=fake_execute): - with pytest.raises(RuntimeError, match=r"no automatic john format mapping"): + with pytest.raises(RuntimeError) as exc_info: await cracker.hashcat( - hashcat_mode=99999, + hashcat_mode=1000, hash_file=str(hash_file), wordlist_path=str(wordlist), ) + assert exc_info.value.__cause__ is original @pytest.mark.asyncio async def test_non_backend_error_propagates_unchanged(self, tmp_path):