diff --git a/.bandit-baseline.json b/.bandit-baseline.json new file mode 100644 index 0000000..3112a5b --- /dev/null +++ b/.bandit-baseline.json @@ -0,0 +1,2837 @@ +{ + "errors": [], + "generated_at": "2026-07-25T17:17:25Z", + "metrics": { + "_totals": { + "CONFIDENCE.HIGH": 112, + "CONFIDENCE.LOW": 2, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 4, + "SEVERITY.LOW": 108, + "SEVERITY.MEDIUM": 2, + "SEVERITY.UNDEFINED": 0, + "loc": 9529, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/__init__.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 10, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/__main__.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 6, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/api.py": { + "CONFIDENCE.HIGH": 42, + "CONFIDENCE.LOW": 2, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 2, + "SEVERITY.LOW": 40, + "SEVERITY.MEDIUM": 2, + "SEVERITY.UNDEFINED": 0, + "loc": 2352, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/attacks.py": { + "CONFIDENCE.HIGH": 4, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 4, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1214, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/cli.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 51, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/formatting.py": { + "CONFIDENCE.HIGH": 2, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 2, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 47, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/llm.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 260, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/main.py": { + "CONFIDENCE.HIGH": 63, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 2, + "SEVERITY.LOW": 61, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 4630, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/menu.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 69, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/noninteractive.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 122, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/__init__.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 246, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/_suppress.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 28, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/pushover.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 52, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/settings.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 141, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/tailer.py": { + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 156, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/progress.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 53, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/username_detect.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 92, + "nosec": 0, + "skipped_tests": 0 + } + }, + "results": [ + { + "code": "57 total = int(r.headers.get(\"content-length\") or 0)\n58 except Exception:\n59 pass\n60 downloaded = 0\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 58, + "line_range": [ + 58, + 59 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "96 os.remove(temp_path)\n97 except Exception:\n98 pass\n99 return False\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 97, + "line_range": [ + 97, + 98 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "270 import atexit\n271 import subprocess\n272 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 271, + "line_range": [ + 271 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "275 self._rpc = f\"127.0.0.1:{self._port}\"\n276 self._proc = subprocess.Popen(\n277 [\n278 \"transmission-daemon\",\n279 \"-f\",\n280 \"-g\",\n281 self._cfg_dir,\n282 \"--port\",\n283 str(self._port),\n284 \"--rpc-bind-address\",\n285 \"127.0.0.1\",\n286 \"--no-auth\",\n287 \"--download-dir\",\n288 self.save_dir,\n289 \"--no-portmap\",\n290 \"--no-watch-dir\",\n291 ],\n292 stdout=subprocess.DEVNULL,\n293 stderr=subprocess.DEVNULL,\n294 )\n295 deadline = time.monotonic() + self.startup_timeout\n", + "col_offset": 21, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 276, + "line_range": [ + 276, + 277, + 278, + 279, + 280, + 281, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 294 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "275 self._rpc = f\"127.0.0.1:{self._port}\"\n276 self._proc = subprocess.Popen(\n277 [\n278 \"transmission-daemon\",\n279 \"-f\",\n280 \"-g\",\n281 self._cfg_dir,\n282 \"--port\",\n283 str(self._port),\n284 \"--rpc-bind-address\",\n285 \"127.0.0.1\",\n286 \"--no-auth\",\n287 \"--download-dir\",\n288 self.save_dir,\n289 \"--no-portmap\",\n290 \"--no-watch-dir\",\n291 ],\n292 stdout=subprocess.DEVNULL,\n293 stderr=subprocess.DEVNULL,\n294 )\n295 deadline = time.monotonic() + self.startup_timeout\n", + "col_offset": 21, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 276, + "line_range": [ + 276, + 277, + 278, + 279, + 280, + 281, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 294 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "296 while time.monotonic() < deadline:\n297 probe = subprocess.run(\n298 [\"transmission-remote\", self._rpc, \"-l\"],\n299 capture_output=True,\n300 )\n301 if probe.returncode == 0:\n", + "col_offset": 20, + "end_col_offset": 13, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 297, + "line_range": [ + 297, + 298, + 299, + 300 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "296 while time.monotonic() < deadline:\n297 probe = subprocess.run(\n298 [\"transmission-remote\", self._rpc, \"-l\"],\n299 capture_output=True,\n300 )\n301 if probe.returncode == 0:\n", + "col_offset": 20, + "end_col_offset": 13, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 297, + "line_range": [ + 297, + 298, + 299, + 300 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "310 def _stop(self):\n311 import subprocess\n312 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 311, + "line_range": [ + 311 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "317 try:\n318 subprocess.run(\n319 [\"transmission-remote\", self._rpc, \"--exit\"],\n320 capture_output=True,\n321 )\n322 except Exception:\n", + "col_offset": 16, + "end_col_offset": 17, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 318, + "line_range": [ + 318, + 319, + 320, + 321 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "317 try:\n318 subprocess.run(\n319 [\"transmission-remote\", self._rpc, \"--exit\"],\n320 capture_output=True,\n321 )\n322 except Exception:\n", + "col_offset": 16, + "end_col_offset": 17, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 318, + "line_range": [ + 318, + 319, + 320, + 321 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "321 )\n322 except Exception:\n323 pass\n324 if self._proc is not None:\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 322, + "line_range": [ + 322, + 323 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "333 self._proc.kill()\n334 except Exception:\n335 pass\n336 except Exception:\n", + "col_offset": 16, + "end_col_offset": 24, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 334, + "line_range": [ + 334, + 335 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "335 pass\n336 except Exception:\n337 pass\n338 if self._cfg_dir:\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 336, + "line_range": [ + 336, + 337 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "346 import re\n347 import subprocess\n348 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 347, + "line_range": [ + 347 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "349 before_ids = {e[\"id\"] for e in self.list()}\n350 result = subprocess.run(\n351 [\n352 \"transmission-remote\",\n353 self._rpc,\n354 \"-a\",\n355 torrent_path,\n356 ],\n357 capture_output=True,\n358 text=True,\n359 )\n360 out = result.stdout or \"\"\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 350, + "line_range": [ + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 359 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "349 before_ids = {e[\"id\"] for e in self.list()}\n350 result = subprocess.run(\n351 [\n352 \"transmission-remote\",\n353 self._rpc,\n354 \"-a\",\n355 torrent_path,\n356 ],\n357 capture_output=True,\n358 text=True,\n359 )\n360 out = result.stdout or \"\"\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 350, + "line_range": [ + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 359 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "373 def list(self) -> list:\n374 import subprocess\n375 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 374, + "line_range": [ + 374 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "375 \n376 result = subprocess.run(\n377 [\"transmission-remote\", self._rpc, \"-l\"],\n378 capture_output=True,\n379 text=True,\n380 )\n381 if result.returncode != 0:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 376, + "line_range": [ + 376, + 377, + 378, + 379, + 380 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "375 \n376 result = subprocess.run(\n377 [\"transmission-remote\", self._rpc, \"-l\"],\n378 capture_output=True,\n379 text=True,\n380 )\n381 if result.returncode != 0:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 376, + "line_range": [ + 376, + 377, + 378, + 379, + 380 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "429 def info_file(self, torrent_id: int) -> str:\n430 import subprocess\n431 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 430, + "line_range": [ + 430 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "431 \n432 result = subprocess.run(\n433 [\n434 \"transmission-remote\",\n435 self._rpc,\n436 f\"-t{torrent_id}\",\n437 \"--info-files\",\n438 ],\n439 capture_output=True,\n440 text=True,\n441 )\n442 if result.returncode != 0:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 432, + "line_range": [ + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 439, + 440, + 441 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "431 \n432 result = subprocess.run(\n433 [\n434 \"transmission-remote\",\n435 self._rpc,\n436 f\"-t{torrent_id}\",\n437 \"--info-files\",\n438 ],\n439 capture_output=True,\n440 text=True,\n441 )\n442 if result.returncode != 0:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 432, + "line_range": [ + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 439, + 440, + 441 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "472 def remove(self, torrent_id: int):\n473 import subprocess\n474 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 473, + "line_range": [ + 473 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "474 \n475 subprocess.run(\n476 [\n477 \"transmission-remote\",\n478 self._rpc,\n479 f\"-t{torrent_id}\",\n480 \"--remove\",\n481 ],\n482 capture_output=True,\n483 )\n484 \n", + "col_offset": 8, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 475, + "line_range": [ + 475, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "474 \n475 subprocess.run(\n476 [\n477 \"transmission-remote\",\n478 self._rpc,\n479 f\"-t{torrent_id}\",\n480 \"--remove\",\n481 ],\n482 capture_output=True,\n483 )\n484 \n", + "col_offset": 8, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 475, + "line_range": [ + 475, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "512 return path\n513 except Exception:\n514 pass\n515 default = os.path.join(os.getcwd(), \"wordlists\")\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 513, + "line_range": [ + 513, + 514 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "532 return path\n533 except Exception:\n534 pass\n535 default = os.path.join(os.getcwd(), \"rules\")\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 533, + "line_range": [ + 533, + 534 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "550 return shlex.split(tuning)\n551 except Exception:\n552 pass\n553 return []\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 551, + "line_range": [ + 551, + 552 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "570 return expanded\n571 except Exception:\n572 pass\n573 return os.path.expanduser(\"~/.hashcat/hashcat.potfile\")\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 571, + "line_range": [ + 571, + 572 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "816 break\n817 except Exception:\n818 continue\n819 if hashmob_api_key:\n", + "col_offset": 12, + "end_col_offset": 24, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 817, + "line_range": [ + 817, + 818 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "838 print(f\"[+] Fetching wordlist page: {wordlist_uri}\")\n839 r = requests.get(wordlist_uri, headers=headers)\n840 if r.status_code != 200:\n", + "col_offset": 12, + "end_col_offset": 55, + "filename": "hate_crack/api.py", + "issue_confidence": "LOW", + "issue_cwe": { + "id": 400, + "link": "https://cwe.mitre.org/data/definitions/400.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Call to requests without timeout", + "line_number": 839, + "line_range": [ + 839 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b113_request_without_timeout.html", + "test_id": "B113", + "test_name": "request_without_timeout" + }, + { + "code": "889 print(f\"[+] Downloading .torrent file from: {torrent_link}\")\n890 r2 = requests.get(torrent_link, headers=headers, stream=True)\n891 content_type = r2.headers.get(\"Content-Type\", \"\")\n", + "col_offset": 9, + "end_col_offset": 65, + "filename": "hate_crack/api.py", + "issue_confidence": "LOW", + "issue_cwe": { + "id": 400, + "link": "https://cwe.mitre.org/data/definitions/400.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Call to requests without timeout", + "line_number": 890, + "line_range": [ + 890 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b113_request_without_timeout.html", + "test_id": "B113", + "test_name": "request_without_timeout" + }, + { + "code": "972 indices.update(range(start, end + 1))\n973 except Exception:\n974 continue\n975 else:\n", + "col_offset": 16, + "end_col_offset": 28, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 973, + "line_range": [ + 973, + 974 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "977 indices.add(int(part))\n978 except Exception:\n979 continue\n980 return sorted(i for i in indices if 1 <= i <= max_index)\n", + "col_offset": 16, + "end_col_offset": 28, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 978, + "line_range": [ + 978, + 979 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "1104 if ht == \"0\":\n1105 return hashlib.md5(raw).hexdigest()\n1106 if ht == \"100\":\n", + "col_offset": 15, + "end_col_offset": 31, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 327, + "link": "https://cwe.mitre.org/data/definitions/327.html" + }, + "issue_severity": "HIGH", + "issue_text": "Use of weak MD5 hash for security. Consider usedforsecurity=False", + "line_number": 1105, + "line_range": [ + 1105 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b324_hashlib.html", + "test_id": "B324", + "test_name": "hashlib" + }, + { + "code": "1106 if ht == \"100\":\n1107 return hashlib.sha1(raw).hexdigest()\n1108 if ht == \"1400\":\n", + "col_offset": 15, + "end_col_offset": 32, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 327, + "link": "https://cwe.mitre.org/data/definitions/327.html" + }, + "issue_severity": "HIGH", + "issue_text": "Use of weak SHA1 hash for security. Consider usedforsecurity=False", + "line_number": 1107, + "line_range": [ + 1107 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b324_hashlib.html", + "test_id": "B324", + "test_name": "hashlib" + }, + { + "code": "1961 raise\n1962 except Exception:\n1963 # If stdin status can't be determined, continue normally.\n1964 pass\n1965 api_harness = HashviewAPI(hashview_url, hashview_api_key, debug=debug_mode)\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 1962, + "line_range": [ + 1962, + 1963, + 1964 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "2124 return key\n2125 except Exception:\n2126 continue\n2127 return None\n", + "col_offset": 12, + "end_col_offset": 24, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2125, + "line_range": [ + 2125, + 2126 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2449 indices.update(range(start, end + 1))\n2450 except Exception:\n2451 continue\n2452 else:\n", + "col_offset": 20, + "end_col_offset": 32, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2450, + "line_range": [ + 2450, + 2451 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2454 indices.add(int(part))\n2455 except Exception:\n2456 continue\n2457 return sorted(i for i in indices if 1 <= i <= max_index)\n", + "col_offset": 20, + "end_col_offset": 32, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2455, + "line_range": [ + 2455, + 2456 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2518 indices.update(range(start, end + 1))\n2519 except Exception:\n2520 continue\n2521 else:\n", + "col_offset": 16, + "end_col_offset": 28, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2519, + "line_range": [ + 2519, + 2520 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2523 indices.add(int(part))\n2524 except Exception:\n2525 continue\n2526 return sorted(i for i in indices if 1 <= i <= max_index)\n", + "col_offset": 16, + "end_col_offset": 28, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2524, + "line_range": [ + 2524, + 2525 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2597 \"\"\"Extract a .7z archive using the 7z or 7za command.\"\"\"\n2598 import subprocess\n2599 \n", + "col_offset": 4, + "end_col_offset": 21, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 2598, + "line_range": [ + 2598 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "2609 print(f\"Extracting {archive_path} to {output_dir} ...\")\n2610 result = subprocess.run(\n2611 [sevenz_bin, \"e\", \"-y\", archive_path],\n2612 capture_output=True,\n2613 text=True,\n2614 cwd=output_dir,\n2615 )\n2616 print(result.stdout)\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2610, + "line_range": [ + 2610, + 2611, + 2612, + 2613, + 2614, + 2615 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "17 readline.parse_and_bind(\"set completion-query-items -1\")\n18 except Exception:\n19 pass\n20 try:\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/attacks.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 18, + "line_range": [ + 18, + 19 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "21 readline.parse_and_bind(\"tab: complete\")\n22 except Exception:\n23 pass\n24 try:\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/attacks.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 22, + "line_range": [ + 22, + 23 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "25 readline.parse_and_bind(\"bind ^I rl_complete\")\n26 except Exception:\n27 pass\n28 readline.set_completer(completer)\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/attacks.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 26, + "line_range": [ + 26, + 27 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "111 combined_choice = f\"{combined_choice} -r {rule_path}\"\n112 except Exception:\n113 continue\n114 selected_rules.append(combined_choice)\n", + "col_offset": 20, + "end_col_offset": 32, + "filename": "hate_crack/attacks.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 112, + "line_range": [ + 112, + 113 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "8 return width\n9 except Exception:\n10 pass\n11 try:\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/formatting.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 9, + "line_range": [ + 9, + 10 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "14 return width\n15 except Exception:\n16 pass\n17 return default\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/formatting.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 15, + "line_range": [ + 15, + 16 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "19 import signal\n20 import subprocess\n21 import shlex\n", + "col_offset": 0, + "end_col_offset": 17, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 20, + "line_range": [ + 20 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "40 REQUESTS_AVAILABLE = True\n41 except Exception:\n42 pass\n43 \n", + "col_offset": 0, + "end_col_offset": 8, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 41, + "line_range": [ + 41, + 42 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "689 try:\n690 test_result = subprocess.run(\n691 [binary_path],\n692 stdout=subprocess.PIPE,\n693 stderr=subprocess.PIPE,\n694 timeout=2,\n695 )\n696 # Binary should show usage and exit with error code (that's expected)\n", + "col_offset": 30, + "end_col_offset": 17, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 690, + "line_range": [ + 690, + 691, + 692, + 693, + 694, + 695 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "858 popen_kwargs = {\"stdin\": stdin} if stdin is not None else {}\n859 hcatProcess = subprocess.Popen(cmd, **popen_kwargs)\n860 interrupted = False\n", + "col_offset": 18, + "end_col_offset": 55, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 859, + "line_range": [ + 859 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "865 gen.wait()\n866 except Exception:\n867 pass\n868 except KeyboardInterrupt:\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 866, + "line_range": [ + 866, + 867 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "874 gen.kill()\n875 except Exception:\n876 pass\n877 finally:\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 875, + "line_range": [ + 875, + 876 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "1016 \"\"\"Run `git pull && git fetch --tags && make install` in the repo root.\"\"\"\n1017 import subprocess\n1018 \n", + "col_offset": 4, + "end_col_offset": 21, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 1017, + "line_range": [ + 1017 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "1021 # site-packages when installed rather than the source checkout.\n1022 git_root_result = subprocess.run(\n1023 [\"git\", \"rev-parse\", \"--show-toplevel\"],\n1024 cwd=_repo_root,\n1025 capture_output=True,\n1026 text=True,\n1027 )\n1028 if git_root_result.returncode != 0:\n", + "col_offset": 22, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1022, + "line_range": [ + 1022, + 1023, + 1024, + 1025, + 1026, + 1027 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1021 # site-packages when installed rather than the source checkout.\n1022 git_root_result = subprocess.run(\n1023 [\"git\", \"rev-parse\", \"--show-toplevel\"],\n1024 cwd=_repo_root,\n1025 capture_output=True,\n1026 text=True,\n1027 )\n1028 if git_root_result.returncode != 0:\n", + "col_offset": 22, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1022, + "line_range": [ + 1022, + 1023, + 1024, + 1025, + 1026, + 1027 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1039 # there's no origin/main ref to auto-create a tracking branch from.\n1040 fetch_result = subprocess.run(\n1041 [\"git\", \"fetch\", \"--tags\", \"origin\"],\n1042 cwd=repo_root,\n1043 capture_output=True,\n1044 text=True,\n1045 )\n1046 if fetch_result.returncode != 0:\n", + "col_offset": 19, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1040, + "line_range": [ + 1040, + 1041, + 1042, + 1043, + 1044, + 1045 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1039 # there's no origin/main ref to auto-create a tracking branch from.\n1040 fetch_result = subprocess.run(\n1041 [\"git\", \"fetch\", \"--tags\", \"origin\"],\n1042 cwd=repo_root,\n1043 capture_output=True,\n1044 text=True,\n1045 )\n1046 if fetch_result.returncode != 0:\n", + "col_offset": 19, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1040, + "line_range": [ + 1040, + 1041, + 1042, + 1043, + 1044, + 1045 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1062 # local `main` tracking origin/main so future manual pulls also work.\n1063 branch_result = subprocess.run(\n1064 [\"git\", \"symbolic-ref\", \"--short\", \"HEAD\"],\n1065 cwd=repo_root,\n1066 capture_output=True,\n1067 text=True,\n1068 )\n1069 branch = branch_result.stdout.strip() if branch_result.returncode == 0 else \"\"\n", + "col_offset": 20, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1063, + "line_range": [ + 1063, + 1064, + 1065, + 1066, + 1067, + 1068 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1062 # local `main` tracking origin/main so future manual pulls also work.\n1063 branch_result = subprocess.run(\n1064 [\"git\", \"symbolic-ref\", \"--short\", \"HEAD\"],\n1065 cwd=repo_root,\n1066 capture_output=True,\n1067 text=True,\n1068 )\n1069 branch = branch_result.stdout.strip() if branch_result.returncode == 0 else \"\"\n", + "col_offset": 20, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1063, + "line_range": [ + 1063, + 1064, + 1065, + 1066, + 1067, + 1068 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1071 if branch and branch != \"main\":\n1072 status = subprocess.run(\n1073 [\"git\", \"status\", \"--porcelain\"],\n1074 cwd=repo_root,\n1075 capture_output=True,\n1076 text=True,\n1077 )\n1078 if status.stdout.strip():\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1072, + "line_range": [ + 1072, + 1073, + 1074, + 1075, + 1076, + 1077 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1071 if branch and branch != \"main\":\n1072 status = subprocess.run(\n1073 [\"git\", \"status\", \"--porcelain\"],\n1074 cwd=repo_root,\n1075 capture_output=True,\n1076 text=True,\n1077 )\n1078 if status.stdout.strip():\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1072, + "line_range": [ + 1072, + 1073, + 1074, + 1075, + 1076, + 1077 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1086 print(f\"\\n Switching from '{branch}' to 'main' to pick up the release tag...\")\n1087 checkout = subprocess.run(\n1088 # -B creates/resets a local `main` pointing at origin/main so this\n1089 # works whether or not a local `main` already exists (e.g. a stale\n1090 # master-only clone that has never had a main branch).\n1091 [\"git\", \"checkout\", \"-B\", \"main\", \"origin/main\"],\n1092 cwd=repo_root,\n1093 capture_output=True,\n1094 text=True,\n1095 )\n1096 if checkout.returncode != 0:\n", + "col_offset": 19, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1087, + "line_range": [ + 1087, + 1088, + 1089, + 1090, + 1091, + 1092, + 1093, + 1094, + 1095 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1086 print(f\"\\n Switching from '{branch}' to 'main' to pick up the release tag...\")\n1087 checkout = subprocess.run(\n1088 # -B creates/resets a local `main` pointing at origin/main so this\n1089 # works whether or not a local `main` already exists (e.g. a stale\n1090 # master-only clone that has never had a main branch).\n1091 [\"git\", \"checkout\", \"-B\", \"main\", \"origin/main\"],\n1092 cwd=repo_root,\n1093 capture_output=True,\n1094 text=True,\n1095 )\n1096 if checkout.returncode != 0:\n", + "col_offset": 19, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1087, + "line_range": [ + 1087, + 1088, + 1089, + 1090, + 1091, + 1092, + 1093, + 1094, + 1095 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1104 # origin/main rather than a dangling branch.master.merge ref.\n1105 subprocess.run(\n1106 [\"git\", \"branch\", \"--set-upstream-to=origin/main\", \"main\"],\n1107 cwd=repo_root,\n1108 capture_output=True,\n1109 text=True,\n1110 )\n1111 \n", + "col_offset": 8, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1105, + "line_range": [ + 1105, + 1106, + 1107, + 1108, + 1109, + 1110 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1104 # origin/main rather than a dangling branch.master.merge ref.\n1105 subprocess.run(\n1106 [\"git\", \"branch\", \"--set-upstream-to=origin/main\", \"main\"],\n1107 cwd=repo_root,\n1108 capture_output=True,\n1109 text=True,\n1110 )\n1111 \n", + "col_offset": 8, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1105, + "line_range": [ + 1105, + 1106, + 1107, + 1108, + 1109, + 1110 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1123 f\"git pull origin main && git fetch --tags && make install && {uv} sync --reinstall-package hate_crack\",\n1124 shell=True,\n1125 cwd=repo_root,\n1126 )\n1127 if result.returncode != 0:\n1128 print(\"\\n Upgrade failed. Check the output above for errors.\\n\")\n1129 raise SystemExit(1)\n1130 \n1131 print(\"\\n Upgrade complete. Please restart hate_crack.\\n\")\n1132 raise SystemExit(0)\n1133 \n1134 \n1135 def check_for_updates():\n", + "col_offset": 13, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "HIGH", + "issue_text": "subprocess call with shell=True identified, security issue.", + "line_number": 1124, + "line_range": [ + 1116, + 1117, + 1118, + 1119, + 1120, + 1121, + 1122, + 1123, + 1124, + 1125, + 1126 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b602_subprocess_popen_with_shell_equals_true.html", + "test_id": "B602", + "test_name": "subprocess_popen_with_shell_equals_true" + }, + { + "code": "1166 _run_upgrade()\n1167 except Exception:\n1168 pass\n1169 \n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 1167, + "line_range": [ + 1167, + 1168 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "1226 readline.parse_and_bind(\"tab: complete\")\n1227 except Exception:\n1228 pass\n1229 try:\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 1227, + "line_range": [ + 1227, + 1228 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "1230 readline.parse_and_bind(\"bind ^I rl_complete\")\n1231 except Exception:\n1232 pass\n1233 readline.set_completer(path_completer)\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 1231, + "line_range": [ + 1231, + 1232 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "1293 ):\n1294 sort_proc = subprocess.Popen(\n1295 [\"sort\", \"-u\"],\n1296 stdin=subprocess.PIPE,\n1297 stdout=dst,\n1298 text=True,\n1299 env={**os.environ, \"LC_ALL\": \"C\"},\n1300 )\n1301 for line in src:\n", + "col_offset": 24, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1294, + "line_range": [ + 1294, + 1295, + 1296, + 1297, + 1298, + 1299, + 1300 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1293 ):\n1294 sort_proc = subprocess.Popen(\n1295 [\"sort\", \"-u\"],\n1296 stdin=subprocess.PIPE,\n1297 stdout=dst,\n1298 text=True,\n1299 env={**os.environ, \"LC_ALL\": \"C\"},\n1300 )\n1301 for line in src:\n", + "col_offset": 24, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1294, + "line_range": [ + 1294, + 1295, + 1296, + 1297, + 1298, + 1299, + 1300 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1423 _maybe_append_username_flag(cmd)\n1424 result = subprocess.run(\n1425 cmd,\n1426 stdout=subprocess.PIPE,\n1427 stderr=subprocess.DEVNULL,\n1428 check=False,\n1429 )\n1430 with open(output_path, \"w\") as out:\n", + "col_offset": 13, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1424, + "line_range": [ + 1424, + 1425, + 1426, + 1427, + 1428, + 1429 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1581 _write_delimited_field(f\"{hcatHashFile}.out\", f\"{hcatHashFile}.working\", 2)\n1582 hcatProcess = subprocess.Popen(\n1583 [\n1584 sys.executable,\n1585 os.path.join(hate_path, \"PACK\", \"statsgen.py\"),\n1586 f\"{hcatHashFile}.working\",\n1587 \"-o\",\n1588 f\"{hcatHashFile}.masks\",\n1589 ]\n1590 )\n1591 try:\n", + "col_offset": 18, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1582, + "line_range": [ + 1582, + 1583, + 1584, + 1585, + 1586, + 1587, + 1588, + 1589, + 1590 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1596 \n1597 hcatProcess = subprocess.Popen(\n1598 [\n1599 sys.executable,\n1600 os.path.join(hate_path, \"PACK\", \"maskgen.py\"),\n1601 f\"{hcatHashFile}.masks\",\n1602 \"--targettime\",\n1603 str(hcatTargetTime),\n1604 \"--optindex\",\n1605 \"-q\",\n1606 \"--pps\",\n1607 \"14000000000\",\n1608 \"--minlength=7\",\n1609 \"-o\",\n1610 f\"{hcatHashFile}.hcmask\",\n1611 ]\n1612 )\n1613 try:\n", + "col_offset": 18, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1597, + "line_range": [ + 1597, + 1598, + 1599, + 1600, + 1601, + 1602, + 1603, + 1604, + 1605, + 1606, + 1607, + 1608, + 1609, + 1610, + 1611, + 1612 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1673 ):\n1674 expander_proc = subprocess.Popen(\n1675 [expander_path], stdin=src, stdout=subprocess.PIPE\n1676 )\n1677 expander_stdout = expander_proc.stdout\n", + "col_offset": 28, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1674, + "line_range": [ + 1674, + 1675, + 1676 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1679 raise RuntimeError(\"expander stdout pipe was not created\")\n1680 sort_proc = subprocess.Popen(\n1681 [\"sort\", \"-u\"],\n1682 stdin=expander_stdout,\n1683 stdout=dst,\n1684 env={**os.environ, \"LC_ALL\": \"C\"},\n1685 )\n1686 hcatProcess = sort_proc\n", + "col_offset": 24, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1680, + "line_range": [ + 1680, + 1681, + 1682, + 1683, + 1684, + 1685 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1679 raise RuntimeError(\"expander stdout pipe was not created\")\n1680 sort_proc = subprocess.Popen(\n1681 [\"sort\", \"-u\"],\n1682 stdin=expander_stdout,\n1683 stdout=dst,\n1684 env={**os.environ, \"LC_ALL\": \"C\"},\n1685 )\n1686 hcatProcess = sort_proc\n", + "col_offset": 24, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1680, + "line_range": [ + 1680, + 1681, + 1682, + 1683, + 1684, + 1685 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1814 _append_potfile_arg(hashcat_cmd)\n1815 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1816 assert generator_proc.stdout is not None\n", + "col_offset": 25, + "end_col_offset": 80, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1815, + "line_range": [ + 1815 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1815 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1816 assert generator_proc.stdout is not None\n1817 _run_hcat_cmd(\n", + "col_offset": 8, + "end_col_offset": 48, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.", + "line_number": 1816, + "line_range": [ + 1816 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b101_assert_used.html", + "test_id": "B101", + "test_name": "assert_used" + }, + { + "code": "1860 _append_potfile_arg(hashcat_cmd)\n1861 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1862 assert generator_proc.stdout is not None\n", + "col_offset": 25, + "end_col_offset": 80, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1861, + "line_range": [ + 1861 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1861 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1862 assert generator_proc.stdout is not None\n1863 _run_hcat_cmd(\n", + "col_offset": 8, + "end_col_offset": 48, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.", + "line_number": 1862, + "line_range": [ + 1862 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b101_assert_used.html", + "test_id": "B101", + "test_name": "assert_used" + }, + { + "code": "1895 _append_potfile_arg(hashcat_cmd)\n1896 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1897 assert generator_proc.stdout is not None\n", + "col_offset": 25, + "end_col_offset": 80, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1896, + "line_range": [ + 1896 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1896 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1897 assert generator_proc.stdout is not None\n1898 _run_hcat_cmd(\n", + "col_offset": 8, + "end_col_offset": 48, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.", + "line_number": 1897, + "line_range": [ + 1897 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b101_assert_used.html", + "test_id": "B101", + "test_name": "assert_used" + }, + { + "code": "1970 _yolo_wordlists = list_wordlist_files(hcatWordlists)\n1971 hcatLeft = random.choice(_yolo_wordlists)\n1972 hcatRight = random.choice(_yolo_wordlists)\n", + "col_offset": 23, + "end_col_offset": 53, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 330, + "link": "https://cwe.mitre.org/data/definitions/330.html" + }, + "issue_severity": "LOW", + "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.", + "line_number": 1971, + "line_range": [ + 1971 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b311-random", + "test_id": "B311", + "test_name": "blacklist" + }, + { + "code": "1971 hcatLeft = random.choice(_yolo_wordlists)\n1972 hcatRight = random.choice(_yolo_wordlists)\n1973 left_path = os.path.join(hcatWordlists, hcatLeft)\n", + "col_offset": 24, + "end_col_offset": 54, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 330, + "link": "https://cwe.mitre.org/data/definitions/330.html" + }, + "issue_severity": "LOW", + "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.", + "line_number": 1972, + "line_range": [ + 1972 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b311-random", + "test_id": "B311", + "test_name": "blacklist" + }, + { + "code": "2582 with _open_wordlist(source_file) as stdin_f:\n2583 hcatProcess = subprocess.Popen(\n2584 [hcstat2gen_bin, hcstat2_path], stdin=stdin_f, stderr=subprocess.PIPE\n2585 )\n2586 try:\n", + "col_offset": 26, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2583, + "line_range": [ + 2583, + 2584, + 2585 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2705 _append_potfile_arg(hashcat_cmd)\n2706 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n2707 try:\n", + "col_offset": 21, + "end_col_offset": 76, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2706, + "line_range": [ + 2706 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2760 with _open_wordlist(prince_base) as base:\n2761 prince_proc = subprocess.Popen(prince_cmd, stdin=base, stdout=subprocess.PIPE)\n2762 _run_hcat_cmd(\n", + "col_offset": 22, + "end_col_offset": 86, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2761, + "line_range": [ + 2761 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2800 _append_potfile_arg(hashcat_cmd)\n2801 pcfg_proc = subprocess.Popen(pcfg_cmd, stdout=subprocess.PIPE)\n2802 _run_hcat_cmd(\n", + "col_offset": 16, + "end_col_offset": 66, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2801, + "line_range": [ + 2801 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2858 try:\n2859 subprocess.run(cmd, check=True)\n2860 os.replace(tmp_path, cache_path)\n", + "col_offset": 12, + "end_col_offset": 43, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2859, + "line_range": [ + 2859 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2905 with _open_wordlist(wordlist) as wl_file:\n2906 permute_proc = subprocess.Popen(\n2907 [permute_path], stdin=wl_file, stdout=subprocess.PIPE\n2908 )\n2909 _run_hcat_cmd(\n", + "col_offset": 23, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2906, + "line_range": [ + 2906, + 2907, + 2908 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2987 print(f\"[*] Running: {_format_cmd(cmd)}\")\n2988 proc = subprocess.Popen(cmd)\n2989 try:\n", + "col_offset": 11, + "end_col_offset": 32, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2988, + "line_range": [ + 2988 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3042 _debug_cmd(hashcat_cmd)\n3043 enum_proc = subprocess.Popen(\n3044 enum_cmd, cwd=model_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE\n3045 )\n3046 try:\n", + "col_offset": 16, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 3043, + "line_range": [ + 3043, + 3044, + 3045 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3140 with open(f\"{hcatHashFile}.combined\", \"wb\") as combined_out:\n3141 combine_proc = subprocess.Popen(\n3142 [combine_path, f\"{hcatHashFile}.working\", f\"{hcatHashFile}.working\"],\n3143 stdout=subprocess.PIPE,\n3144 )\n3145 hcatProcess = subprocess.Popen(\n", + "col_offset": 23, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 3141, + "line_range": [ + 3141, + 3142, + 3143, + 3144 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3144 )\n3145 hcatProcess = subprocess.Popen(\n3146 [\"sort\", \"-u\"],\n3147 stdin=combine_proc.stdout,\n3148 stdout=combined_out,\n3149 env={**os.environ, \"LC_ALL\": \"C\"},\n3150 )\n3151 combine_proc.stdout.close()\n", + "col_offset": 22, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 3145, + "line_range": [ + 3145, + 3146, + 3147, + 3148, + 3149, + 3150 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "3144 )\n3145 hcatProcess = subprocess.Popen(\n3146 [\"sort\", \"-u\"],\n3147 stdin=combine_proc.stdout,\n3148 stdout=combined_out,\n3149 env={**os.environ, \"LC_ALL\": \"C\"},\n3150 )\n3151 combine_proc.stdout.close()\n", + "col_offset": 22, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 3145, + "line_range": [ + 3145, + 3146, + 3147, + 3148, + 3149, + 3150 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3235 try:\n3236 result = subprocess.run(\n3237 [generate_rules_path, str(rule_count)],\n3238 capture_output=True,\n3239 text=True,\n3240 check=True,\n3241 )\n3242 with open(rules_path, \"w\") as f:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 3236, + "line_range": [ + 3236, + 3237, + 3238, + 3239, + 3240, + 3241 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3451 print(f\"Found session file: {session_file}\")\n3452 except Exception:\n3453 pass\n3454 \n", + "col_offset": 16, + "end_col_offset": 24, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 3452, + "line_range": [ + 3452, + 3453 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "4220 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4221 result = subprocess.run(\n4222 [len_bin, str(min_len), str(max_len)], stdin=fin, stdout=fout\n4223 )\n4224 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4221, + "line_range": [ + 4221, + 4222, + 4223 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4230 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4231 result = subprocess.run([req_bin, str(mask)], stdin=fin, stdout=fout)\n4232 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 77, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4231, + "line_range": [ + 4231 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4238 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4239 result = subprocess.run([req_bin, str(mask)], stdin=fin, stdout=fout)\n4240 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 77, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4239, + "line_range": [ + 4239 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4249 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4250 result = subprocess.run(cmd, stdin=fin, stdout=fout)\n4251 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 60, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4250, + "line_range": [ + 4250 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4257 with open(infile, \"rb\") as fin:\n4258 result = subprocess.run([splitlen_bin, outdir], stdin=fin)\n4259 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 66, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4258, + "line_range": [ + 4258 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4264 rli_bin = os.path.join(hate_path, \"hashcat-utils/bin/rli.bin\")\n4265 result = subprocess.run([rli_bin, infile, outfile, *remove_files])\n4266 return result.returncode == 0\n", + "col_offset": 13, + "end_col_offset": 70, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4265, + "line_range": [ + 4265 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4272 with open(outfile, \"wb\") as fout:\n4273 result = subprocess.run([rli2_bin, infile, remove_file], stdout=fout)\n4274 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 77, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4273, + "line_range": [ + 4273 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4280 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4281 result = subprocess.run(\n4282 [gate_bin, str(mod), str(offset)], stdin=fin, stdout=fout\n4283 )\n4284 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4281, + "line_range": [ + 4281, + 4282, + 4283 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4335 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4336 result = subprocess.run([cleanup_path, str(mode)], stdin=fin, stdout=fout)\n4337 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 82, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4336, + "line_range": [ + 4336 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4345 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4346 result = subprocess.run([optimize_path], stdin=fin, stdout=fout)\n4347 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 72, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4346, + "line_range": [ + 4346 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4447 ),\n4448 shell=True,\n4449 )\n4450 try:\n4451 pipalProcess.wait()\n4452 except KeyboardInterrupt:\n4453 print(\"Killing PID {0}...\".format(str(pipalProcess.pid)))\n4454 pipalProcess.kill()\n4455 print(\"Pipal file is at \" + hcatHashFilePipal + \".pipal\\n\")\n4456 import sys\n4457 \n", + "col_offset": 27, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "HIGH", + "issue_text": "subprocess call with shell=True identified, security issue.", + "line_number": 4448, + "line_range": [ + 4441, + 4442, + 4443, + 4444, + 4445, + 4446, + 4447, + 4448, + 4449 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b602_subprocess_popen_with_shell_equals_true.html", + "test_id": "B602", + "test_name": "subprocess_popen_with_shell_equals_true" + }, + { + "code": "186 line = line_bytes.decode(\"utf-8\", errors=\"replace\")\n187 except Exception:\n188 continue\n189 label = self._extract_username(line) or self.attack_name\n", + "col_offset": 12, + "end_col_offset": 24, + "filename": "hate_crack/notify/tailer.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 187, + "line_range": [ + 187, + 188 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + } + ] +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddd9cfa..91178d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,12 @@ jobs: - name: Install dependencies run: uv sync --dev - - name: Ruff + - name: Ruff (lint) run: uv run ruff check hate_crack + - name: Ruff (format) + run: uv run ruff format --check hate_crack + - name: Ty run: uv run ty check hate_crack @@ -43,3 +46,48 @@ jobs: env: HATE_CRACK_SKIP_INIT: "1" run: uv run pytest -q + + # Bandit SAST against the committed baseline (.bandit-baseline.json): only + # findings NOT already in the baseline fail the build. hate_crack shells out + # to hashcat extensively, so the baseline captures the reviewed low-severity + # subprocess/shlex findings. Config lives in [tool.bandit] in pyproject.toml. + bandit: + name: Bandit SAST + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Bandit (vs baseline) + run: >- + uvx --from "bandit[toml]==1.9.4" bandit + -r hate_crack -c pyproject.toml -b .bandit-baseline.json + + # pip-audit: fail on dependencies with known CVEs (PyPI / OSV advisory DB). + # Audits the synced project environment. PYSEC-2026-2447 (diskcache 5.6.3, a + # transitive dep via instructor -> atomic-agents) is ignored because no fixed + # release exists upstream; revisit when diskcache ships a fix. + pip-audit: + name: pip-audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.13" + enable-cache: true + cache-dependency-glob: "**/uv.lock" + + - name: Install dependencies + run: uv sync + + - name: Audit dependencies + run: >- + uv run --with pip-audit==2.10.0 pip-audit + --progress-spinner off --ignore-vuln PYSEC-2026-2447 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97347e0..0654ee6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,6 @@ jobs: with: persist-credentials: false - - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: generate_release_notes: true diff --git a/.gitignore b/.gitignore index 9efc18a..7fdf8f2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ research/ 4_char_all all_hashes.enabled some_histories + +# Local agent tooling and instructions - intentionally not published +CLAUDE.md +.claude/ +docs/plans/ +docs/superpowers/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/CHANGELOG.md b/CHANGELOG.md index 620e3ac..f244fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,160 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.3] - 2026-07-25 + +### Added + +- **Private-key commit gate.** `prek.toml` now runs the `detect-private-key` + hook at the pre-commit stage. The repo previously had no secret-scanning gate + of any kind — bandit only covers `hate_crack/`, so nothing inspected config + files, docs, or test fixtures for committed key material. + +### Removed + +- **Local agent tooling is no longer published.** `CLAUDE.md`, `.claude/`, + `docs/plans/`, and `docs/superpowers/` were development aids rather than part + of the shipped project. They are now gitignored and were removed from the + repository, including from its history. +- **`audit-docs` post-commit hook.** Dropped from `prek.toml` along with the + `.claude/audit-docs.sh` script it invoked. + +## [2.14.2] - 2026-07-25 + +### Fixed + +- **Pipal base-word parsing.** `pipal()` built one rigid regex that required + *exactly* `pipal_count` consecutive base-word lines, so any cracked set with + fewer unique base words than `pipal_count` (default 10 — the common case on + small cracks) matched nothing and returned no base words. The `Top N base + words` section is now parsed line by line, returning up to `pipal_count` + words and stopping at the end of the section. +- **Shell-safe pipal invocation.** The pipal subprocess is now spawned with + list-form arguments instead of a `shell=True` formatted string, so hash-file + paths containing shell metacharacters can no longer be interpreted as + commands. + +### Changed + +- Renamed the internal `_omen_pick_training_wordlist` helper to + `_pick_training_wordlist`, since it is shared by the OMEN, Markov-adjacent, + and LLM (wordlist mode) attacks rather than being OMEN-specific. + +## [2.14.1] - 2026-07-25 + +### Fixed + +- **Tab completion on custom file-path prompts.** The `p. Enter a custom path` + branches of the OMEN and Markov training pickers, the combipow wordlist + prompt, and the rule cleanup/optimize output-path prompts used a bare + `input()` with no readline completer, so TAB did nothing. They now route + through `select_file_with_autocomplete` for consistent path autocompletion. +- **Stale completer leak.** `select_file_with_autocomplete` and the + `_configure_readline`-based pickers now drop the path completer after a + selection, so later numeric-menu and y/n prompts no longer inherit file-path + tab completion. + +## [2.14.0] - 2026-07-24 + +### Added + +- **Non-interactive attack subcommands** for scripting (issue #17). Launch a + single attack without the menu: `quick` (wordlist + optional `--rules`), + `dict` (configured-wordlist methodology), `brute` (`--min`/`--max`), and + `topmask` (`--target-time`). Preprocessing prompts auto-accept their + defaults, and the process returns a clean exit code (0 on success, non-zero + on a bad hash file, hash type, wordlist, or rule name). + +## [2.13.1] - 2026-07-24 + +### Fixed + +- **`OLLAMA_HOST` values that include a scheme no longer produce a malformed URL** + (issue #119). The Ollama base URL was built as `"http://" + OLLAMA_HOST`, so a value in + the form Ollama's own tooling accepts — `http://box:11434` or + `https://ollama.example.com` — became `http://http://box:11434` and the LLM attack could + not connect. `http://` is now prepended only when no scheme is present, and trailing + slashes are stripped because callers append paths (`f"{ollamaUrl}/v1"`). Reaching a remote + Ollama over TLS works as a result. The bare `host:port` default is unchanged. + +## [2.13.0] - 2026-07-24 + +### Added + +- **Cracked-password generation mode** for the LLM attack. Once a session has recovered + plaintexts, option 3 feeds them back to the model, which infers the organization's own + password conventions and generates new candidates in that style. Offered only when + `.out` has content, and it uses a dedicated prompt that tells the model not to + re-emit passwords already cracked. +- **Target research pre-fills the industry and location prompts.** In target mode, entering + the company name asks the local model to recall that organization's industry and location, + then offers them as editable defaults (Enter accepts, typing overrides). Values are + labelled as model guesses rather than verified OSINT, whitespace-collapsed, and capped at + 80 characters. Research runs entirely against the local Ollama server, so the client name + is never sent to a third party. Any failure or timeout falls back to blank prompts and + never blocks the attack. Disable with `ollamaAutoResearch: false`. +- **Live progress spinner** with an elapsed-seconds counter during Ollama generation, so a + model loading into VRAM is distinguishable from a hang. Automatically suppressed when + stdout is not a TTY. +- **`ollamaMaxSampleLines`** (default 500) caps how many sample passwords are sent to the + model, for both wordlist and cracked-password modes. + +### Fixed + +- **A large sample wordlist no longer stalls the LLM attack.** Wordlist mode read every line + into memory and pasted all of them into the prompt, so pointing it at `rockyou.txt` + materialized hundreds of megabytes and overran the model's context window — which looked + like a hang. The file is now streamed and evenly sampled across its whole length, and the + count actually used is reported (`Sampled 500 of 14,344,391 passwords from wordlist.`). +- **`HATE_CRACK_ARROW_MENU=1` now works in the LLM and OMEN submenus.** They hand-rolled + `print()` + `input()` instead of the shared menu helper, so arrow-key navigation silently + did nothing there. +- **A typo in a wordlist or generation-mode prompt no longer aborts the whole attack.** The + pickers and submenus re-prompt instead of dropping back to the main menu, and offer an + explicit cancel. + +### Changed + +- **Interactive prompt formatting normalized** — the `[*] ` marker is no longer used on + input prompts (it denotes status output elsewhere), and default-value hints use a single + form. + +### Build + +- **Local `uv` Python pinned to 3.13** via `.python-version`. `requires-python = ">=3.13"` + meant a fresh worktree picked CPython 3.15.0a7 and failed to build pyo3 0.26 (via + `jiter`/`fastuuid`/`pydantic-core`). CI already pinned 3.13. + +## [2.12.0] - 2026-07-24 + +### Changed + +- **LLM attack now uses the Atomic Agents framework** for structured (JSON) candidate + generation instead of raw HTTP + regex line-parsing. Candidate generation lives in the + new `hate_crack/llm.py` module. +- **Default Ollama model is now `qwen2.5:32b`** (was `mistral`), chosen for reliable + structured-output adherence. + +### Added + +- **Wordlist (denylist) generation mode** for the LLM attack is now reachable from the + menu: select the LLM attack (option 12), then choose "Wordlist" to derive basewords from + a sample wordlist. + +### Fixed + +- **The LLM attack no longer hangs forever waiting on Ollama.** Generation requests are now + bounded by a configurable timeout (`ollamaTimeout` in `config.json`, default 300 seconds). + Previously, if Ollama accepted the connection but never replied — most commonly a large + model still loading into VRAM — hate_crack sat at a frozen prompt with no recourse but + Ctrl-C. When the timeout fires you now get a specific message naming the elapsed timeout + and the setting to raise, instead of a misleading "ensure Ollama is running" hint. + +### Removed + +- **Automatic model pulling.** hate_crack no longer pulls missing Ollama models; pull them + yourself with `ollama pull `. + ## [2.11.4] - 2026-07-24 ### Added diff --git a/README.md b/README.md index f19da8e..636349f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ ``` - ___ ___ __ _________ __ + ___ ___ __ _________ __ / | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __ / ~ \__ \\ __\/ __ \ / \ \/\_ __ \__ \ _/ ___\| |/ / -\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < +\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ ``` @@ -27,7 +27,7 @@ Or download a pre-built binary from https://hashcat.net/hashcat/ and set `hcatPa ### 2. Download hate_crack -Clone with submodules (required for hashcat-utils, princeprocessor, and optionally omen): +Clone with submodules (required for hashcat-utils, princeprocessor, pcfg_cracker, and optionally omen): ```bash git clone --recurse-submodules https://github.com/trustedsec/hate_crack.git @@ -46,7 +46,7 @@ Then customize configuration in `config.json` if needed (wordlist paths, API key The easiest way is to run `make` (or `make install`), which auto-detects your OS and installs: - External dependencies (p7zip, transmission-daemon / transmission-remote) -- Builds submodules (hashcat-utils, princeprocessor, and optionally omen) +- Builds submodules (hashcat-utils, princeprocessor, pcfg_cracker, and optionally omen) - Python dependencies via uv and a CLI shim at `~/.local/bin/hate_crack` ```bash @@ -96,6 +96,12 @@ Core logic is now split into modules under `hate_crack/`: - `hate_crack/api.py`: Hashview, Weakpass, and Hashmob integrations (downloads/menus/helpers). - `hate_crack/attacks.py`: menu attack handlers. - `hate_crack/hashmob_wordlist.py`: Hashmob wordlist utilities (thin wrapper; calls into api.py). +- `hate_crack/llm.py`: structured (JSON) LLM candidate generation via Atomic Agents. +- `hate_crack/menu.py`: shared menu renderer, including optional arrow-key navigation. +- `hate_crack/noninteractive.py`: dispatcher for the scripted attack subcommands. +- `hate_crack/notify/`: notification package (Pushover backend, per-crack tailer). +- `hate_crack/username_detect.py`: detects `username:hash` input files to decide on hashcat's `--username`. +- `hate_crack/formatting.py`, `hate_crack/progress.py`: output formatting and progress display helpers. - `hate_crack/main.py`: main CLI implementation. The top-level `hate_crack.py` remains the main entry point and orchestrates these modules. @@ -143,7 +149,7 @@ Config is also searched in: - The repo root and package directory - `~/hate_crack`, `~/hate-crack`, or `~/.hate_crack` -**Note:** The `hcatPath` in `config.json` is for the hashcat binary location only (optional if hashcat is in PATH). Hate_crack assets (hashcat-utils, princeprocessor, omen) are loaded from the repository directory and bundled automatically by `make install`. +**Note:** The `hcatPath` in `config.json` is for the hashcat binary location only (optional if hashcat is in PATH). Hate_crack assets (hashcat-utils, princeprocessor, pcfg_cracker, omen) are loaded from the repository directory and bundled automatically by `make install`. ### Run as a script The script uses a `uv` shebang. Make it executable and run: @@ -159,6 +165,30 @@ You can also use Python directly: python hate_crack.py ``` +### Non-interactive / scripted usage + +For automation you can launch a single attack directly, bypassing the menu. The attack name is the first argument, followed by the hash file and hashcat hash type. Preprocessing prompts (computer-account filtering, LM-first brute force, duplicate-account dedup) auto-accept their defaults in this mode. The process exits `0` on success and non-zero on error (missing hash file, non-numeric hash type, missing wordlist, or an unknown rule filename). + +```bash +# Quick crack: one wordlist + optional rule(s) from the rules directory +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule + +# Chain two rules in a single run +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule+d3ad0ne.rule + +# Run two rules as two separate passes +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule d3ad0ne.rule + +# Canned dictionary methodology (uses your configured wordlists) +hate_crack dict hashes.txt 1000 + +# Brute force lengths 1-8 +hate_crack brute hashes.txt 1000 --min 1 --max 8 + +# Top-mask attack targeting ~4 hours +hate_crack topmask hashes.txt 1000 --target-time 4 +``` + ------------------------------------------------------------------- ## Troubleshooting @@ -459,18 +489,65 @@ Set Hashview credentials in `config.json`: #### Ollama Configuration -The LLM Attack (option 15) uses Ollama to generate password candidates. Configure the model and context window in `config.json`: +The LLM Attack (option 12) uses Ollama to generate password candidates. Configure the model, context window, and request timeout in `config.json`: ```json { - "ollamaModel": "mistral", - "ollamaNumCtx": 2048 + "ollamaModel": "qwen2.5:32b", + "ollamaNumCtx": 2048, + "ollamaTimeout": 300 } ``` -- **`ollamaModel`** — The Ollama model to use for candidate generation (default: `mistral`). +- **`ollamaModel`** — The Ollama model used for candidate generation (default: `qwen2.5:32b`). The LLM attack uses structured (JSON) output, so choose a model with good tool/JSON support. - **`ollamaNumCtx`** — Context window size for the model (default: `2048`). -- The Ollama URL defaults to `http://localhost:11434`. Ensure Ollama is running before using the LLM Attack. +- **`ollamaTimeout`** — Seconds to wait for a generation response before giving up (default: `300`). Raise this if a large model is still loading into VRAM on the first request, which can otherwise exceed the timeout; hate_crack prints the elapsed timeout and this setting's name when it fires. +- **`ollamaMaxSampleLines`** — Maximum number of lines drawn from the source file and included in the LLM prompt when using **Wordlist** or **Cracked passwords** mode (default: `500`). Lines are sampled evenly across the whole file so the prompt reflects the wordlist's full character range rather than just the head. Set to a larger value if the model has a big context window (`ollamaNumCtx`) and you want richer coverage; set it lower to reduce prompt size and generation latency. Values ≤ 0 are treated as 500. +- **`ollamaAutoResearch`** — When `true` (default), **Target info** mode asks the local model to suggest the industry and location as soon as you have typed the company name, and offers them as editable prompt defaults. Set to `false` to always get blank prompts (useful with a slow model, since research costs one extra round-trip before the attack starts). +- The Ollama URL defaults to `http://localhost:11434` (override via the `OLLAMA_HOST` env var). Ensure Ollama is running and the model is pulled (`ollama pull qwen2.5:32b`) before using the LLM Attack — hate_crack no longer auto-pulls missing models. + +The attack offers three generation modes: + +1. **Target info** — company / industry / location; the model derives candidates from those details. + + After you type the company name, hate_crack asks the same local model what it already knows about that organization and pre-fills the **Industry** and **Location** prompts with the answers, shown in parentheses: + + ``` + Company name: Acme Rail Services + + [!] The values in parentheses below are the local model's GUESSES, not verified OSINT. + Press Enter to accept, or type your own value to override. + Industry (freight rail maintenance): + Location (Omaha, Nebraska): + ``` + + Press Enter to accept a suggestion or type over it. These values are the model's recollection, **not OSINT** — treat them as a starting point, not intelligence about the client. The lookup uses only the local Ollama server, so the client name never leaves the host; there are no web or third-party API calls. If the model does not recognize the organization (the common case for small clients), it returns nothing and you get plain blank prompts: + + ``` + Company name: Acme Rail Services + Industry: + Location: + ``` + + A research failure — timeout, Ollama not running, empty answer — never blocks the attack; it just falls back to blank prompts. Set `ollamaAutoResearch` to `false` to skip research entirely. +2. **Wordlist** — derive basewords from a sample wordlist. +3. **Cracked passwords** — feed the plaintexts already recovered this session (`.out`) back to the model so it can infer the target organization's own password conventions (basewords, seasons, years, suffixes, leetspeak) and generate *new* candidates in the same style. This option is only listed once at least one hash has been cracked; the sample is capped by `ollamaMaxSampleLines` exactly like Wordlist mode. + +#### PCFG Configuration + +The PCFG Attack (option 20) and PRINCE-LING Attack (option 21) use the `pcfg_cracker` submodule. Configure them in `config.json`: + +```json +{ + "pcfgRuleset": "DEFAULT", + "pcfgMaxCandidates": 50000000, + "pcfgPrinceLingMaxCandidates": 10000000 +} +``` + +- **`pcfgRuleset`** — Name of the trained grammar to use (default: `DEFAULT`), resolved to `pcfg_cracker/Rules//`. Train your own with pcfg_cracker's `trainer.py` and set this to the ruleset name. +- **`pcfgMaxCandidates`** — Maximum candidates `pcfg_guesser.py` emits for the PCFG attack (default: `50000000`). +- **`pcfgPrinceLingMaxCandidates`** — Maximum base words `prince_ling.py` writes into the cached PRINCE base wordlist (default: `10000000`). ### Notifications (menu option 82) @@ -551,10 +628,10 @@ $ hashcat --help |grep -i ntlm ``` $ ./hate_crack.py 1000 - ___ ___ __ _________ __ + ___ ___ __ _________ __ / | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __ / ~ \__ \\ __\/ __ \ / \ \/\_ __ \__ \ _/ ___\| |/ / -\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < +\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ Version 2.0 @@ -675,20 +752,22 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (7) Hybrid Attack (8) Pathwell Top 100 Mask Brute Force Crack (9) PRINCE Attack - (13) Bandrel Methodology - (14) Loopback Attack - (15) LLM Attack - (16) OMEN Attack - (17) Ad-hoc Mask Attack - (18) Markov Brute Force Attack - (19) N-gram Attack - (20) Permutation Attack - (21) Random Rules Attack - (22) Combipow Passphrase Attack + (10) Bandrel Methodology + (11) Loopback Attack + (12) LLM Attack + (13) OMEN Attack + (14) Ad-hoc Mask Attack + (15) Markov Brute Force Attack + (16) N-gram Attack + (17) Permutation Attack + (18) Random Rules Attack + (19) Combipow Passphrase Attack + (20) PCFG Attack + (21) PRINCE-LING Attack (80) Wordlist Tools - (81) Rule File Tools + (82) Notifications (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -703,6 +782,10 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi Select a task: ``` + +Option `94 — Hashview API` is only listed when `hashview_api_key` is set in `config.json`. + +The YOLO, Middle, and Thorough Combinator attacks were previously at keys 10-12. They now live in the Combinator Attacks submenu (option 6) along with Combinator3 and CombinatorX. ------------------------------------------------------------------- #### Quick Crack Runs a dictionary attack against wordlists in your `hcatOptimizedWordlists` directory (falls back to `hcatWordlists` if not configured) and optionally applies rules. Multiple rules can be selected by comma-separated list, and chains can be created with the '+' symbol. Pressing Enter at the wordlist prompt uses the configured optimized wordlists directory as the default. @@ -716,9 +799,9 @@ Which rule(s) would you like to run? (99) YOLO...run all of the rules Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol. For example 1+1 will run best64.rule chained twice and 1,2 would run best64.rule and then d3ad0ne.rule sequentially. -Choose wisely: +Choose wisely: ``` - + @@ -735,7 +818,7 @@ Runs several attack methods provided by Martin Bos (formerly known as pure_hate) * Hybrid Attack * Extra - Just For Good Measure - Runs a dictionary attack using `rockyou.txt` with chained `combinator.rule` and `InsidePro-PasswordsPro.rule` rules - + #### Brute Force Attack Brute forces all characters with the choice of a minimum and maximum password length. @@ -817,9 +900,12 @@ Uses hashcat's loopback mode to feed cracked passwords from the current session #### LLM Attack Uses a local Ollama instance to generate password candidates for a capture-the-flag scenario. Prompts for the fake company name, industry, and location, then sends these details to the configured LLM model to produce likely password candidates using industry terms and company name permutations. The generated candidates are fed into a hashcat wordlist+rules attack. -* Requires a running Ollama instance (default: `http://localhost:11434`) -* Configurable model and context window via `config.json` (see Ollama Configuration below) -* Prompts for target company name, industry, and location +* Requires a running Ollama instance (default: `http://localhost:11434`, override with `OLLAMA_HOST`) with the model already pulled — hate_crack does not auto-pull +* Candidate generation uses structured (JSON) output via Atomic Agents, so pick a model with good schema adherence (default: `qwen2.5:32b`) +* Configurable model, context window, request timeout, and sample size via `config.json` (see Ollama Configuration below) +* Prompts for target company name, industry, and location. The industry and location prompts are pre-filled with the local model's guesses about the named organization (editable, and clearly labelled as guesses rather than verified OSINT); disable with `ollamaAutoResearch: false` +* Alternatively derives basewords from a sample **wordlist**, or from the **cracked passwords** of the current session (`.out`) so the model mirrors the target organization's own password conventions and produces new candidates in that style (only offered once something has been cracked) +* A live spinner with an elapsed-seconds counter runs during generation, and requests are bounded by `ollamaTimeout` so a model stuck loading into VRAM reports a timeout instead of hanging #### OMEN Attack Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model from a wordlist and generate password candidates. This attack learns patterns from known passwords and generates new candidates based on those patterns. @@ -862,6 +948,14 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### N-gram Attack +Generates n-gram candidates from a corpus file using `ngramX.bin` from hashcat-utils and pipes them into hashcat. + +* Prompts for a corpus file with tab completion, defaulting to the configured wordlist directory +* Prompts for an n-gram group size (default 3) +* Gzip-compressed corpus files are auto-detected and decompressed on the fly +* Useful when you have target-relevant prose (scraped site copy, leaked documents, internal wiki exports) rather than a password list + #### Permutation Attack Generates all character permutations of each word in a targeted wordlist and pipes them to hashcat via `permute.bin` from hashcat-utils. @@ -887,6 +981,23 @@ Generates all unique non-empty subset combinations from a short wordlist using ` * Aborts with a clear message if the wordlist exceeds 63 lines (hard limit) * Candidates are piped directly to hashcat stdin +#### PCFG Attack +Uses [pcfg_cracker](https://github.com/lakiw/pcfg_cracker) to generate candidates from a Probabilistic Context-Free Grammar, piping `pcfg_guesser.py` output directly into hashcat's stdin mode. A PCFG models password *structure* (baseword + digits + symbol, capitalization habits, keyboard walks) with learned probabilities, so candidates come out roughly in descending likelihood order. + +* Requires the `pcfg_cracker` submodule. Presence is checked at startup and reported non-fatally: if it is missing, the PCFG attacks are simply unavailable. Run `make` to fetch it. +* Uses the trained grammar named by `pcfgRuleset` in `config.json` (default `DEFAULT`), read from `pcfg_cracker/Rules//` +* Candidate count is capped by `pcfgMaxCandidates` (default 50,000,000) +* hate_crack does not wrap grammar training. To build a grammar from a target-specific password set, run pcfg_cracker's own `trainer.py` and point `pcfgRuleset` at the resulting ruleset name + +#### PRINCE-LING Attack +Uses pcfg_cracker's `prince_ling.py` to derive an optimized PRINCE base wordlist from a trained grammar, then hands it to the existing PRINCE attack. PRINCE-LING picks base words the grammar says are actually productive, so the PRINCE combination space is far less wasteful than pointing PRINCE at a generic wordlist. + +* Requires the `pcfg_cracker` submodule and a trained ruleset directory, same as the PCFG attack +* The generated wordlist is cached at `/pcfg_prince_ling_.txt` and reused across sessions +* Regenerates only when the ruleset directory is newer than the cached wordlist, so retraining a grammar invalidates the cache automatically +* Generation is written to a temporary file and atomically moved into place; a failed or interrupted run cleans up its partial file and leaves any existing cache intact +* Base wordlist size is capped by `pcfgPrinceLingMaxCandidates` (default 10,000,000) + #### Wordlist Tools (option 80) A submenu of wordlist preprocessing utilities using hashcat-utils binaries. All tools read from and write to files on disk. All file and directory path prompts support tab completion. @@ -941,7 +1052,7 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi * Download specific wordlists or entire collections * Automatic extraction of compressed archives * Progress tracking for torrent downloads - + ------------------------------------------------------------------- ### Version History diff --git a/config.json.example b/config.json.example index c696519..ec89e9a 100644 --- a/config.json.example +++ b/config.json.example @@ -25,8 +25,11 @@ "hashview_url": "http://localhost:8443", "hashview_api_key": "", "hashmob_api_key": "", - "ollamaModel": "mistral", + "ollamaModel": "qwen2.5:32b", "ollamaNumCtx": 2048, + "ollamaTimeout": 300, + "ollamaMaxSampleLines": 500, + "ollamaAutoResearch": true, "omenTrainingList": "rockyou.txt", "omenMaxCandidates": 50000000, "pcfgRuleset": "DEFAULT", diff --git a/hate_crack/__init__.py b/hate_crack/__init__.py index 9741c5a..0543c68 100644 --- a/hate_crack/__init__.py +++ b/hate_crack/__init__.py @@ -12,8 +12,6 @@ except _PackageNotFoundError: # "2.5.1.post1.dev0" → "2.5.1" # "2.5.1" → "2.5.1" __version__ = _re.sub(r"(\.post\d+|\.dev\d+)", "", _raw_version) -__version_tuple__ = tuple( - int(x) if x.isdigit() else x for x in __version__.split(".") -) +__version_tuple__ = tuple(int(x) if x.isdigit() else x for x in __version__.split(".")) __all__ = ["__version__", "__version_tuple__"] diff --git a/hate_crack/api.py b/hate_crack/api.py index 767657a..5160e97 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -129,7 +129,9 @@ def _streamed_download( allow_redirects=allow_redirects, ) as r: r.raise_for_status() - return _stream_response_to_file(r, dest_path, label=label, show_progress=show_progress) + return _stream_response_to_file( + r, dest_path, label=label, show_progress=show_progress + ) except KeyboardInterrupt: raise except Exception as e: @@ -407,8 +409,10 @@ class TransmissionSession: percent_done = 0.0 # Status is tokens[7] (best-effort); name is the rest. status = tokens[7] if len(tokens) > 8 else "" - name = " ".join(tokens[8:]) if len(tokens) > 8 else ( - tokens[-1] if len(tokens) > 1 else "" + name = ( + " ".join(tokens[8:]) + if len(tokens) > 8 + else (tokens[-1] if len(tokens) > 1 else "") ) entries.append( { @@ -486,10 +490,7 @@ class TransmissionSession: if not entries: break for entry in entries: - if ( - entry["percent_done"] >= 100.0 - and entry["id"] not in completed_ids - ): + if entry["percent_done"] >= 100.0 and entry["id"] not in completed_ids: completed_ids.add(entry["id"]) file_name = self.info_file(entry["id"]) on_complete(entry["id"], file_name) @@ -627,9 +628,7 @@ def run_torrent_session(torrent_files, save_dir, *, print_fn=print) -> None: failed += 1 return abs_path = ( - file_path - if os.path.isabs(file_path) - else os.path.join(save_dir, file_path) + file_path if os.path.isabs(file_path) else os.path.join(save_dir, file_path) ) if abs_path.endswith(".7z"): ok = extract_with_7z(abs_path, save_dir, remove_archive=True) @@ -653,9 +652,7 @@ def run_torrent_session(torrent_files, save_dir, *, print_fn=print) -> None: except KeyboardInterrupt: print_fn("\n[!] Torrent download interrupted.") raise - print_fn( - f"[i] Torrent session complete: {completed} succeeded, {failed} failed." - ) + print_fn(f"[i] Torrent session complete: {completed} succeeded, {failed} failed.") def fetch_all_weakpass_wordlists_multithreaded(total_pages=None, threads=10): @@ -678,10 +675,9 @@ def fetch_all_weakpass_wordlists_multithreaded(total_pages=None, threads=10): last_page = None if isinstance(wordlists_raw, dict): # Check multiple possible locations for last_page - last_page = ( - wordlists_raw.get("last_page") - or wordlists_raw.get("meta", {}).get("last_page") - ) + last_page = wordlists_raw.get("last_page") or wordlists_raw.get( + "meta", {} + ).get("last_page") if "data" in wordlists_raw: wordlists_raw = wordlists_raw["data"] else: @@ -729,7 +725,9 @@ def fetch_all_weakpass_wordlists_multithreaded(total_pages=None, threads=10): seen.add(wl["name"]) return result else: - print("[!] Weakpass page 1 returned no results; falling back to 67 pages") + print( + "[!] Weakpass page 1 returned no results; falling back to 67 pages" + ) total_pages = 67 entries1 = [] except Exception as e: @@ -892,7 +890,8 @@ def fetch_torrent_metadata(torrent_url, save_dir=None, wordlist_id=None): r2 = requests.get(torrent_link, headers=headers, stream=True) content_type = r2.headers.get("Content-Type", "") local_filename = os.path.join( - torrent_dir, filename if filename.endswith(".torrent") else filename + ".torrent" + torrent_dir, + filename if filename.endswith(".torrent") else filename + ".torrent", ) if r2.status_code == 200 and not content_type.startswith("text/html"): with open(local_filename, "wb") as f: @@ -1010,7 +1009,9 @@ def weakpass_wordlist_menu(rank=-1): if not torrent_url: print(f"[!] Missing torrent URL for selection {idx}") continue - meta = fetch_torrent_metadata(torrent_url, save_dir=save_dir, wordlist_id=entry.get("id")) + meta = fetch_torrent_metadata( + torrent_url, save_dir=save_dir, wordlist_id=entry.get("id") + ) if meta: torrent_files.append(meta) if torrent_files: @@ -1352,7 +1353,9 @@ class HashviewAPI: all_hashfiles = self.get_hashfiles_by_type(hash_type) customer_hfs = [ - hf for hf in all_hashfiles if int(hf.get("customer_id", 0)) == int(customer_id) + hf + for hf in all_hashfiles + if int(hf.get("customer_id", 0)) == int(customer_id) ] # The type-scoped endpoint already returns the hash_type, but normalize @@ -1570,7 +1573,12 @@ class HashviewAPI: return resp.json() def download_left_hashes( - self, customer_id, hashfile_id, output_file=None, hash_type=None, potfile_path=None + self, + customer_id, + hashfile_id, + output_file=None, + hash_type=None, + potfile_path=None, ): import sys @@ -1676,7 +1684,11 @@ class HashviewAPI: ) # Append found hash:clear pairs to the potfile - resolved_potfile = potfile_path if potfile_path is not None else get_hcat_potfile_path() + resolved_potfile = ( + potfile_path + if potfile_path is not None + else get_hcat_potfile_path() + ) if resolved_potfile: appended = 0 with open(resolved_potfile, "a", encoding="utf-8") as pf: @@ -1810,7 +1822,9 @@ class HashviewAPI: r"filename=\"?([^\";]+)\"?", content_disp, re.IGNORECASE ) output_file = ( - os.path.basename(match.group(1)) if match else f"wordlist_{wordlist_id}.gz" + os.path.basename(match.group(1)) + if match + else f"wordlist_{wordlist_id}.gz" ) if not os.path.isabs(output_file): @@ -2155,7 +2169,9 @@ def download_hashmob_wordlist(file_name, out_path): def _attempt(): _hashmob_limiter.wait() - with requests.get(url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r: + with requests.get( + url, headers=headers, stream=True, timeout=60, allow_redirects=True + ) as r: if r.status_code == 429: raise _Hashmob429() r.raise_for_status() @@ -2171,7 +2187,9 @@ def download_hashmob_wordlist(file_name, out_path): real_url = match.group(1) print(f"Found meta refresh redirect to: {real_url}") return _streamed_download(real_url, out_path, label=file_name) - print("Error: Received HTML instead of file. Possible permission or quota issue.") + print( + "Error: Received HTML instead of file. Possible permission or quota issue." + ) return False return _stream_response_to_file(r, out_path, label=file_name) @@ -2281,19 +2299,31 @@ def download_hashmob_rule(file_name, out_path): print( f"[i] Hashmob rule not in pinned URL list, using public prefix: {file_name}" ) - primary_url = f"https://www.hashmob.net/api/v2/downloads/research/rules/{file_name}" + primary_url = ( + f"https://www.hashmob.net/api/v2/downloads/research/rules/{file_name}" + ) alt_url = f"https://hashmob.net/api/v2/downloads/research/official/hashmob_rules/{file_name}" api_key = get_hashmob_api_key() headers = {"api-key": api_key} if api_key else {} def _attempt(): _hashmob_limiter.wait() - with requests.get(primary_url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r: + with requests.get( + primary_url, headers=headers, stream=True, timeout=60, allow_redirects=True + ) as r: if r.status_code == 429: raise _Hashmob429() if r.status_code == 404 and alt_url: - print(f"[i] Hashmob rule not found at primary URL, trying fallback: {alt_url}") - with requests.get(alt_url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r2: + print( + f"[i] Hashmob rule not found at primary URL, trying fallback: {alt_url}" + ) + with requests.get( + alt_url, + headers=headers, + stream=True, + timeout=60, + allow_redirects=True, + ) as r2: if r2.status_code == 429: raise _Hashmob429() r2.raise_for_status() @@ -2554,9 +2584,7 @@ def download_official_wordlist(file_name, out_path): out_path = sanitize_filename(file_name) dest_dir = get_hcat_wordlists_dir() archive_path = ( - os.path.join(dest_dir, out_path) - if not os.path.isabs(out_path) - else out_path + os.path.join(dest_dir, out_path) if not os.path.isabs(out_path) else out_path ) os.makedirs(os.path.dirname(archive_path), exist_ok=True) ok = _streamed_download(url, archive_path, label=file_name) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 5dee0f4..837bbce 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -7,6 +7,7 @@ from typing import Any from hate_crack import notify as _notify from hate_crack.api import download_hashmob_rules from hate_crack.formatting import print_multicolumn_list +from hate_crack.llm import clean_research_field from hate_crack.menu import interactive_menu @@ -169,9 +170,7 @@ def quick_crack(ctx: Any) -> None: if raw_choice == "": wordlist_choice = default_dir elif raw_choice.isdigit() and 1 <= int(raw_choice) <= len(wordlist_files): - chosen = os.path.join( - list_dir, wordlist_files[int(raw_choice) - 1] - ) + chosen = os.path.join(list_dir, wordlist_files[int(raw_choice) - 1]) if os.path.exists(chosen): wordlist_choice = chosen print(wordlist_choice) @@ -182,6 +181,7 @@ def quick_crack(ctx: Any) -> None: print("Please enter a valid wordlist or wordlist directory.") except ValueError: print("Please enter a valid number.") + readline.set_completer(None) selected_rules = _select_rules(ctx) if selected_rules is None: @@ -249,9 +249,7 @@ def extensive_crack(ctx: Any) -> None: ctx.hcatGoodMeasure(ctx.hcatHashType, ctx.hcatHashFile) ctx.hcatRecycle(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatExtraCount) cracked_after = ctx.lineCount(out_path) if os.path.exists(out_path) else 0 - _notify.notify_job_done( - "Extensive Crack", cracked_after, ctx.hcatHashFile - ) + _notify.notify_job_done("Extensive Crack", cracked_after, ctx.hcatHashFile) # Note: ``cracked_before`` is tracked for potential future per-orchestrator # delta reporting, but today the notify message uses the absolute count # because that matches what single-attack notifications already report. @@ -307,7 +305,9 @@ def combinator_crack(ctx: Any) -> None: print("\n" + "=" * 60) print("COMBINATOR ATTACK") print("=" * 60) - print("Combines 2-8 wordlists. 2 uses hashcat native mode; 3+ use external binaries.") + print( + "Combines 2-8 wordlists. 2 uses hashcat native mode; 3+ use external binaries." + ) print("=" * 60) use_default = ( @@ -317,7 +317,9 @@ def combinator_crack(ctx: Any) -> None: if use_default != "n": base = ctx.hcatCombinationWordlist wordlists = base if isinstance(base, list) else [base] - wordlists = [ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists] + wordlists = [ + ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists + ] if len(wordlists) < 2: print("\n[!] Config does not have at least 2 wordlists.") print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.") @@ -331,14 +333,18 @@ def combinator_crack(ctx: Any) -> None: print("\n[!] Combinator attack requires at least 2 wordlists.") print("Aborting combinator attack.") return - separator = input("\nEnter separator between words (leave blank for none): ").strip() + separator = input( + "\nEnter separator between words (leave blank for none): " + ).strip() if len(wordlists) == 2 and not separator: ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists) elif len(wordlists) == 3 and not separator: ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists) else: - ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None) + ctx.hcatCombinatorX( + ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None + ) def hybrid_crack(ctx: Any) -> None: @@ -487,6 +493,7 @@ def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]: count += 1 else: print(f"Not found: {resolved}") + readline.set_completer(None) return collected @@ -510,45 +517,128 @@ def bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) +def _research_target_suggestions(ctx: Any, company: str) -> dict[str, str]: + """Ask the local model for industry/location suggestions for *company*. + + Returns a dict of cleaned suggestion strings (values may be ''). Research is + a convenience only, so any failure is swallowed here as well as in + ``hcatOllamaResearchTarget``: the operator still gets blank prompts and the + attack proceeds. + """ + if not company: + return {} + + try: + raw = ctx.hcatOllamaResearchTarget(company) + except Exception as e: + print(f"Note: target research unavailable ({e}) — enter the details manually.") + return {} + + suggestions = {} + if isinstance(raw, dict): + for key in ("industry", "location"): + value = clean_research_field(raw.get(key, "")) + if value: + suggestions[key] = value + + if suggestions: + print( + "\n[!] The values in parentheses below are the local model's GUESSES, " + "not verified OSINT." + ) + print(" Press Enter to accept, or type your own value to override.") + return suggestions + + +def _prompt_with_default(label: str, default: Any) -> str: + """Prompt for *label*, showing *default* in parentheses when there is one.""" + suggestion = clean_research_field(default) + if suggestion: + return input(f"{label} ({suggestion}): ").strip() or suggestion + return input(f"{label}: ").strip() + + def ollama_attack(ctx: Any) -> None: _notify.prompt_notify_for_attack("LLM") - print("\n\tLLM Attack") - company = input("Company name: ").strip() - industry = input("Industry: ").strip() - location = input("Location: ").strip() - target_info = { - "company": company, - "industry": industry, - "location": location, - } - ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "target", target_info) + # Cracked-password mode is only offered when this session actually has + # plaintexts to learn from, matching _markov_pick_training_source. + out_path = f"{ctx.hcatHashFile}.out" + has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 + + items: list[tuple[str, str]] = [ + ("1", "Target info (company / industry / location)"), + ("2", "Wordlist (generate basewords from a sample wordlist)"), + ] + if has_cracked: + items.append(("3", "Cracked passwords (current session)")) + items.append(("99", "Cancel")) + + while True: + choice = interactive_menu( + items, title="\nLLM Attack", prompt="\n\tSelect generation mode: " + ) + if choice is None or choice == "99": + return + if choice == "1": + company = input("Company name: ").strip() + suggestions = _research_target_suggestions(ctx, company) + industry = _prompt_with_default("Industry", suggestions.get("industry")) + location = _prompt_with_default("Location", suggestions.get("location")) + ctx.hcatOllama( + ctx.hcatHashType, + ctx.hcatHashFile, + "target", + {"company": company, "industry": industry, "location": location}, + ) + return + elif choice == "2": + path = _pick_training_wordlist(ctx, title="LLM Sample Wordlists") + if not path: + return + ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) + return + elif choice == "3" and has_cracked: + ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path) + return + else: + # Without this the menu just silently redraws and the user cannot + # tell a rejected key from a repainted prompt. + print("\t[!] Invalid selection.") -def _omen_pick_training_wordlist(ctx: Any): - """Show wordlist picker for OMEN training. Returns path or None.""" +def _pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): + """Show wordlist picker. Returns path or None (user cancelled with 'q').""" wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) + # Print the grid once, outside the retry loop: a wordlists directory can + # hold dozens of entries, and repainting the whole thing after every typo + # buries the error message. if wordlist_files: entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] max_len = max((len(e) for e in entries), default=24) print_multicolumn_list( - "Training Wordlists", + title, entries, min_col_width=max_len, max_col_width=max_len, ) print("\tp. Enter a custom path") - sel = input("\n\tSelect wordlist for training: ").strip() - if sel.lower() == "p": - path = input("\n\tPath to training wordlist: ").strip() - return path if path else None - try: - idx = int(sel) - if 1 <= idx <= len(wordlist_files): - return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) - except (ValueError, IndexError): - pass - print("\t[!] Invalid selection.") - return None + print("\tq. Cancel") + while True: + sel = input("\n\tSelect wordlist: ").strip() + if sel.lower() == "q": + return None + if sel.lower() == "p": + path = ctx.select_file_with_autocomplete( + "\tPath to wordlist (tab to autocomplete)" + ) + return path.strip() if path else None + try: + idx = int(sel) + if 1 <= idx <= len(wordlist_files): + return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) + except (ValueError, IndexError): + pass + print("\t[!] Invalid selection.") def omen_attack(ctx: Any) -> None: @@ -570,21 +660,29 @@ def omen_attack(ctx: Any) -> None: info = ctx._omen_model_info(model_dir) trained_with = info.get("training_file", "unknown") if info else "unknown" print(f"\n\tOMEN model found (trained with: {trained_with})") - print("\t1. Use existing model") - print("\t2. Train new model (overwrites existing)") - print("\t3. Cancel") - choice = input("\n\tChoice: ").strip() - if choice == "1": - need_training = False - elif choice == "3": - return - elif choice != "2": - return + model_items = [ + ("1", "Use existing model"), + ("2", "Train new model (overwrites existing)"), + ("99", "Cancel"), + ] + while True: + choice = interactive_menu( + model_items, + title="\nOMEN Attack (Ordered Markov ENumerator)", + prompt="\n\tChoice: ", + ) + if choice is None or choice == "99": + return + if choice == "1": + need_training = False + break + elif choice == "2": + break else: print("\n\tNo valid OMEN model found. Training is required.") if need_training: - training_file = _omen_pick_training_wordlist(ctx) + training_file = _pick_training_wordlist(ctx) if not training_file: return if not ctx.hcatOmenTrain(training_file): @@ -606,11 +704,12 @@ def omen_attack(ctx: Any) -> None: def _markov_pick_training_source(ctx: Any): - """Prompt user to select markov training source. Returns file path or None.""" + """Prompt user to select markov training source. Returns file path or None (user cancelled with 'q').""" out_path = f"{ctx.hcatHashFile}.out" has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) + # Print the grid once, outside the retry loop — see _pick_training_wordlist. entries = [] if has_cracked: entries.append("0) Cracked passwords (current session)") @@ -624,20 +723,25 @@ def _markov_pick_training_source(ctx: Any): max_col_width=max_len, ) print("\tp. Enter a custom path") - sel = input("\n\tSelect training source: ").strip() - if sel == "0" and has_cracked: - return out_path - if sel.lower() == "p": - path = input("\n\tPath to training file: ").strip() - return path if path else None - try: - idx = int(sel) - if 1 <= idx <= len(wordlist_files): - return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) - except (ValueError, IndexError): - pass - print("\t[!] Invalid selection.") - return None + print("\tq. Cancel") + while True: + sel = input("\n\tSelect training source: ").strip() + if sel.lower() == "q": + return None + if sel == "0" and has_cracked: + return out_path + if sel.lower() == "p": + path = ctx.select_file_with_autocomplete( + "\tPath to training file (tab to autocomplete)" + ) + return path.strip() if path else None + try: + idx = int(sel) + if 1 <= idx <= len(wordlist_files): + return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) + except (ValueError, IndexError): + pass + print("\t[!] Invalid selection.") def adhoc_mask_crack(ctx: Any) -> None: @@ -707,13 +811,16 @@ def combipow_crack(ctx: Any) -> None: _notify.prompt_notify_for_attack("Combipow") wordlist = None while wordlist is None: - path = input("\n[*] Enter path to wordlist (max 63 lines recommended): ").strip() + path = ctx.select_file_with_autocomplete( + "Enter path to wordlist (max 63 lines recommended, tab to autocomplete)" + ) + path = path.strip() if path else "" if not path: continue if not os.path.isfile(path): print(f"[!] File not found: {path}") continue - with (gzip.open(path, "rb") if path.endswith(".gz") else open(path, "rb")) as fh: + with gzip.open(path, "rb") if path.endswith(".gz") else open(path, "rb") as fh: line_count = sum(1 for _ in fh) if line_count > 63: print( @@ -725,7 +832,7 @@ def combipow_crack(ctx: Any) -> None: f"[*] Warning: {line_count} lines will generate a large number of combinations." ) wordlist = path - use_space_sep = input("[*] Add spaces between words? (Y/n): ").strip().lower() != "n" + use_space_sep = input("\nAdd spaces between words? (Y/n): ").strip().lower() != "n" ctx.hcatCombipow(ctx.hcatHashType, ctx.hcatHashFile, wordlist, use_space_sep) @@ -735,7 +842,9 @@ def generate_rules_crack(ctx: Any) -> None: print("RANDOM RULES ATTACK") print("=" * 60) print("Generates random hashcat mutation rules and applies them to a wordlist.") - print("Use when known rulesets are exhausted - a chaos mode for rule-space exploration.") + print( + "Use when known rulesets are exhausted - a chaos mode for rule-space exploration." + ) print("=" * 60) raw_count = input("\nNumber of random rules to generate (65536): ").strip() @@ -801,11 +910,15 @@ def generate_rules_crack(ctx: Any) -> None: wordlist_choice = raw_choice else: print("[!] Wordlist not found. Please enter a valid path.") + readline.set_completer(None) return except ValueError: print("Please enter a valid number.") + readline.set_completer(None) - ctx.hcatGenerateRules(ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice) + ctx.hcatGenerateRules( + ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice + ) def ngram_attack(ctx: Any) -> None: @@ -825,7 +938,7 @@ def ngram_attack(ctx: Any) -> None: print("No corpus selected. Aborting ngram attack.") return - group_size_raw = input("\nEnter n-gram group size (default 3): ").strip() + group_size_raw = input("\nEnter n-gram group size (3): ").strip() try: group_size = int(group_size_raw) if group_size_raw else 3 except ValueError: @@ -841,7 +954,9 @@ def permute_crack(ctx: Any) -> None: print("PERMUTATION ATTACK") print("=" * 60) print("Generates ALL character permutations of each word in a targeted wordlist.") - print("WARNING: Scales as N! per word. Only practical for words up to ~8 characters.") + print( + "WARNING: Scales as N! per word. Only practical for words up to ~8 characters." + ) print("Best for: short targeted wordlists (names, abbreviations, known fragments).") print("=" * 60) @@ -867,9 +982,7 @@ def permute_crack(ctx: Any) -> None: wordlist_path = None while wordlist_path is None: - raw = input( - "\nEnter path to a wordlist FILE (tab to autocomplete): " - ).strip() + raw = input("\nEnter path to a wordlist FILE (tab to autocomplete): ").strip() if not raw: continue if not os.path.exists(raw): @@ -879,11 +992,11 @@ def permute_crack(ctx: Any) -> None: print("[!] A directory was provided. Please enter a single wordlist file.") continue wordlist_path = raw + readline.set_completer(None) ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path) - def combinator_submenu(ctx: Any) -> None: items = [ ("1", "Combinator Attack (2-8 wordlists)"), @@ -927,7 +1040,10 @@ def _rule_select_file(ctx: Any, prompt: str = "Rule file: ") -> str: return None _configure_readline(rule_completer) - return input(prompt).strip() + try: + return input(prompt).strip() + finally: + readline.set_completer(None) def rule_cleanup_handler(ctx: Any) -> None: @@ -938,7 +1054,10 @@ def rule_cleanup_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -956,7 +1075,10 @@ def rule_optimize_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -976,7 +1098,10 @@ def rule_cleanup_and_optimize_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -1026,12 +1151,14 @@ def wordlist_filter_length(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return - min_len = int(input("[*] Minimum length: ").strip() or "0") - max_len = int(input("[*] Maximum length: ").strip() or "0") + min_len = int(input("Minimum length: ").strip() or "0") + max_len = int(input("Maximum length: ").strip() or "0") if ctx.wordlist_filter_len(infile, outfile, min_len, max_len): print(f"\n[*] Filtered wordlist written to: {outfile}") else: @@ -1046,12 +1173,16 @@ def wordlist_filter_charclass_include(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return - print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)") - mask = int(input("[*] Enter mask value: ").strip() or "0") + print( + "[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)" + ) + mask = int(input("Mask value: ").strip() or "0") if ctx.wordlist_filter_req_include(infile, outfile, mask): print(f"\n[*] Filtered wordlist written to: {outfile}") else: @@ -1066,12 +1197,14 @@ def wordlist_filter_charclass_exclude(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive)") - mask = int(input("[*] Enter mask value: ").strip() or "0") + mask = int(input("Mask value: ").strip() or "0") if ctx.wordlist_filter_req_exclude(infile, outfile, mask): print(f"\n[*] Filtered wordlist written to: {outfile}") else: @@ -1086,12 +1219,14 @@ def wordlist_cut_substring(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return - offset = int(input("[*] Byte offset to start from: ").strip() or "0") - raw_length = input("[*] Length (leave blank for rest of line): ").strip() + offset = int(input("Byte offset to start from: ").strip() or "0") + raw_length = input("Length (leave blank for rest of line): ").strip() length = int(raw_length) if raw_length else None if ctx.wordlist_cutb(infile, outfile, offset, length): print(f"\n[*] Output written to: {outfile}") @@ -1107,7 +1242,9 @@ def wordlist_split_by_length(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outdir = ctx.select_file_with_autocomplete("[*] Enter output directory path").strip() + outdir = ctx.select_file_with_autocomplete( + "[*] Enter output directory path" + ).strip() if not outdir: print("[!] Output directory cannot be empty.") return @@ -1123,7 +1260,7 @@ def wordlist_subtract_words(ctx: Any) -> None: print("\n[*] Subtract mode:") print(" 1. Single remove file (rli2 - faster for one file)") print(" 2. Multiple remove files (rli)") - mode = input("[*] Choose mode (1/2): ").strip() + mode = input("Choose mode (1/2): ").strip() if mode == "1": infile = ctx.select_file_with_autocomplete( @@ -1138,7 +1275,9 @@ def wordlist_subtract_words(ctx: Any) -> None: if not os.path.isfile(remove_file): print(f"[!] File not found: {remove_file}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return @@ -1153,12 +1292,16 @@ def wordlist_subtract_words(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return raw = ctx.select_file_with_autocomplete( - "[*] Enter remove file paths", allow_multiple=True, base_dir=ctx.hcatWordlists + "[*] Enter remove file paths", + allow_multiple=True, + base_dir=ctx.hcatWordlists, ).strip() remove_files = [r.strip() for r in raw.split(",") if r.strip()] if not remove_files: @@ -1186,7 +1329,7 @@ def wordlist_shard(ctx: Any) -> None: if not outbase: print("[!] Output path cannot be empty.") return - mod = int(input("[*] Shard count (e.g. 4 to split into 4 parts): ").strip() or "0") + mod = int(input("Shard count (e.g. 4 to split into 4 parts): ").strip() or "0") if mod < 2: print("[!] Shard count must be at least 2.") return @@ -1232,7 +1375,9 @@ def wordlist_optimize(ctx: Any) -> None: for p in not_found: print(f" {p}") return - outdir = ctx.select_file_with_autocomplete("[*] Enter output directory path").strip() + outdir = ctx.select_file_with_autocomplete( + "[*] Enter output directory path" + ).strip() if not outdir: print("[!] Output directory cannot be empty.") return diff --git a/hate_crack/cli.py b/hate_crack/cli.py index 4cfcd67..b23a514 100644 --- a/hate_crack/cli.py +++ b/hate_crack/cli.py @@ -55,7 +55,9 @@ def setup_logging(logger: logging.Logger, hate_path: str, debug_mode: bool) -> N logger.addHandler(stream_handler) # Show HTTP requests made by the requests/urllib3 library. debug_handler = logging.StreamHandler(sys.stderr) - debug_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) + debug_handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") + ) urllib3_logger = logging.getLogger("urllib3") urllib3_logger.setLevel(logging.DEBUG) urllib3_logger.addHandler(debug_handler) diff --git a/hate_crack/config.json.example b/hate_crack/config.json.example index c20f6f8..fe8ffb8 100644 --- a/hate_crack/config.json.example +++ b/hate_crack/config.json.example @@ -22,6 +22,11 @@ "hashview_url": "http://localhost:8443", "hashview_api_key": "", "hashmob_api_key": "", + "ollamaModel": "qwen2.5:32b", + "ollamaNumCtx": 2048, + "ollamaTimeout": 300, + "ollamaMaxSampleLines": 500, + "ollamaAutoResearch": true, "passgptModel": "javirandor/passgpt-10characters", "passgptMaxCandidates": 1000000, "passgptBatchSize": 1024, diff --git a/hate_crack/llm.py b/hate_crack/llm.py new file mode 100644 index 0000000..9600489 --- /dev/null +++ b/hate_crack/llm.py @@ -0,0 +1,311 @@ +"""Structured LLM password-candidate generation via Atomic Agents + Ollama. + +Isolates the atomic-agents / instructor dependency. The rest of hate_crack talks +to this module only through ``generate_candidates`` and ``research_target``. +""" + +import instructor +from openai import APITimeoutError, OpenAI +from pydantic import Field + +from atomic_agents import AgentConfig, AtomicAgent, BaseIOSchema +from atomic_agents.context import SystemPromptGenerator + +MAX_CANDIDATE_LEN = 128 +DEFAULT_TIMEOUT_SECONDS = 300.0 + +# Researched target fields are pasted into an interactive prompt as an editable +# default, so they must stay short enough to fit on one terminal line. +MAX_RESEARCH_FIELD_LEN = 80 + + +class LLMTimeoutError(Exception): + """The LLM server accepted the request but did not respond in time. + + Raised instead of ``openai.APITimeoutError`` so callers do not need to import + ``openai`` themselves. + """ + + +class GenerationInput(BaseIOSchema): + """Instruction and context for a password-candidate generation request.""" + + request: str = Field( + ..., description="The full instruction, including any target or sample context." + ) + + +class PasswordCandidatesOutput(BaseIOSchema): + """A structured list of candidate passwords / basewords.""" + + candidates: list[str] = Field( + ..., + description=( + "Candidate passwords, one per list entry. No numbering, bullets, or " + "explanation — just the raw candidate strings." + ), + ) + + +class TargetResearchInput(BaseIOSchema): + """The company name to recall industry and location details for.""" + + company: str = Field( + ..., description="The name of the target organization, exactly as typed." + ) + + +class TargetResearchOutput(BaseIOSchema): + """Recalled industry and location for a named organization, or empty strings.""" + + industry: str = Field( + ..., + description=( + "The organization's industry or sector as a short phrase, e.g. " + "'regional healthcare provider' or 'commercial construction'. Return " + "an empty string if you do not actually recognize this organization." + ), + ) + location: str = Field( + ..., + description=( + "The organization's primary location as 'City, State/Country'. Return " + "an empty string if you do not actually recognize this organization." + ), + ) + + +_RESEARCH_PROMPT = SystemPromptGenerator( + background=[ + "You are assisting a security professional on an authorized penetration " + "test who is about to generate password candidates for a named client " + "organization.", + "You have no internet access. You may only answer from what you already " + "know about the organization.", + "Most client organizations are small and will be completely unknown to " + "you. That is the expected case, not a failure.", + ], + steps=[ + "Decide whether you genuinely recognize this specific organization by name.", + "If you do, recall its industry or sector and its primary location.", + "If you do not recognize it, or you are not reasonably confident, do not " + "guess and do not infer anything from the words in the name.", + ], + output_instructions=[ + "Return an empty string for any field you are not reasonably confident " + "about. An empty field is correct and useful; a fabricated one is harmful " + "because the operator may mistake it for real intelligence.", + "Keep each field under 80 characters.", + "Return only the industry and location fields — no explanations, " + "hedging, caveats, or commentary.", + ], +) + +_TARGET_PROMPT = SystemPromptGenerator( + background=[ + "You are a security professional generating password candidates during an " + "authorized penetration test / capture-the-flag exercise.", + ], + steps=[ + "Study the provided target context (company, industry, location).", + "Derive basewords from the company name and industry terms.", + "Combine basewords with common suffixes, years, and leetspeak substitutions.", + ], + output_instructions=[ + "Return only candidate passwords in the candidates list.", + "Do not include explanations, numbering, or duplicate entries.", + ], +) + +_WORDLIST_PROMPT = SystemPromptGenerator( + background=[ + "You build denylist basewords so users cannot set weak passwords.", + ], + steps=[ + "Study the sample passwords for patterns: capitalization, leetspeak, " + "suffixes, and common substitutions.", + "Produce basewords that capture those patterns.", + ], + output_instructions=[ + "Return only basewords in the candidates list.", + "Do not include explanations, numbering, or duplicate entries.", + ], +) + +_CRACKED_PROMPT = SystemPromptGenerator( + background=[ + "You are a security professional generating password candidates during an " + "authorized penetration test.", + "The passwords you are shown were already recovered from this specific " + "target organization, so they reveal that organization's real password " + "conventions.", + ], + steps=[ + "Study the recovered plaintexts for the organization's conventions: " + "basewords, capitalization, seasons and months, years, separators, " + "suffixes, and leetspeak substitutions.", + "Infer the naming habits behind them (company and product names, local " + "sports teams, site or department names, keyboard walks).", + "Generate NEW candidates that follow the same conventions, varying the " + "basewords, years, and suffixes the organization clearly favours.", + ], + output_instructions=[ + "Return only candidate passwords in the candidates list.", + "Do not repeat any password that appears in the input — those are already " + "cracked and retrying them is wasted work.", + "Do not include explanations, numbering, or duplicate entries.", + ], +) + +_PROMPTS = { + "target": _TARGET_PROMPT, + "wordlist": _WORDLIST_PROMPT, + "cracked": _CRACKED_PROMPT, +} + + +def _build_request(mode: str, context_data: dict) -> str: + """Build the natural-language request string for the given mode.""" + if mode == "target": + company = context_data.get("company", "") + industry = context_data.get("industry", "") + location = context_data.get("location", "") + return ( + f"The target organization is '{company}', a {industry} in {location}. " + "Generate as many plausible password candidates as you can, using " + "permutations of the company name and industry terms with common " + "suffixes, years, and leetspeak substitutions." + ) + if mode == "wordlist": + sample = context_data.get("sample", "") + return ( + "Here are sample passwords. Study their patterns and generate basewords " + "for a denylist:\n" + sample + ) + if mode == "cracked": + sample = context_data.get("sample", "") + return ( + "These passwords were already cracked from the target organization. " + "Study the conventions they reveal and generate as many NEW password " + "candidates as you can that follow the same conventions. Do not repeat " + "any of these:\n" + sample + ) + raise ValueError(f"Unknown LLM generation mode: {mode}") + + +def _build_client(url: str, timeout: float) -> instructor.Instructor: + """Build the instructor-wrapped OpenAI client pointed at an Ollama server.""" + return instructor.from_openai( + OpenAI(base_url=f"{url}/v1", api_key="ollama", timeout=timeout), + mode=instructor.Mode.JSON, + ) + + +def clean_research_field(value: object) -> str: + """Strip and length-cap one researched field; return '' for no suggestion. + + Anything that is not a non-empty string after stripping — including a model + that echoed whitespace or an over-long ramble — becomes '' so the caller + falls back to a plain blank prompt instead of pasting model noise into it. + """ + if not isinstance(value, str): + return "" + cleaned = " ".join(value.split()) + if len(cleaned) > MAX_RESEARCH_FIELD_LEN: + cleaned = cleaned[:MAX_RESEARCH_FIELD_LEN].rstrip() + return cleaned + + +def research_target( + url: str, + model: str, + num_ctx: int, + company: str, + timeout: float = DEFAULT_TIMEOUT_SECONDS, +) -> TargetResearchOutput: + """Ask the local model what it already knows about *company*. + + Returns a ``TargetResearchOutput`` whose ``industry`` and ``location`` are + stripped and capped at ``MAX_RESEARCH_FIELD_LEN``; either may be '' when the + model is not confident, which callers must treat as "no suggestion". + + Uses only the configured local Ollama server — no web lookups, so the client + name never leaves the host. Raises LLMTimeoutError if the request exceeds + ``timeout``; other client/connection errors propagate to the caller. + """ + client = _build_client(url, timeout) + + agent = AtomicAgent[TargetResearchInput, TargetResearchOutput]( + config=AgentConfig( + client=client, + model=model, + system_prompt_generator=_RESEARCH_PROMPT, + model_api_parameters={"extra_body": {"options": {"num_ctx": num_ctx}}}, + ) + ) + + try: + result = agent.run(TargetResearchInput(company=company)) + except APITimeoutError as e: + raise LLMTimeoutError( + f"no response from {url} within {timeout:g} seconds" + ) from e + + return TargetResearchOutput( + industry=clean_research_field(getattr(result, "industry", "")), + location=clean_research_field(getattr(result, "location", "")), + ) + + +def generate_candidates( + url: str, + model: str, + num_ctx: int, + mode: str, + context_data: dict, + timeout: float = DEFAULT_TIMEOUT_SECONDS, +) -> list[str]: + """Generate password candidates via an Ollama-backed AtomicAgent. + + ``timeout`` is the number of seconds to wait for a generation response before + giving up; it bounds the whole request so a server that accepts the + connection but never replies (e.g. a large model still loading into VRAM) + cannot hang the caller forever. + + Returns a deduped, length-capped list of candidate strings (may be empty). + Raises ValueError for an unknown mode and LLMTimeoutError if the request + exceeds ``timeout``. Other client/connection errors propagate to the caller. + """ + request = _build_request(mode, context_data) + + client = _build_client(url, timeout) + # _build_request has already rejected unknown modes, so this lookup is safe. + prompt_generator = _PROMPTS[mode] + + agent = AtomicAgent[GenerationInput, PasswordCandidatesOutput]( + config=AgentConfig( + client=client, + model=model, + system_prompt_generator=prompt_generator, + model_api_parameters={"extra_body": {"options": {"num_ctx": num_ctx}}}, + ) + ) + + try: + result = agent.run(GenerationInput(request=request)) + except APITimeoutError as e: + raise LLMTimeoutError( + f"no response from {url} within {timeout:g} seconds" + ) from e + + seen: set[str] = set() + candidates: list[str] = [] + for raw in getattr(result, "candidates", []) or []: + candidate = str(raw).strip() + if not candidate or len(candidate) > MAX_CANDIDATE_LEN: + continue + if candidate in seen: + continue + seen.add(candidate) + candidates.append(candidate) + return candidates diff --git a/hate_crack/main.py b/hate_crack/main.py index 7734aec..2ac9602 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -21,8 +21,6 @@ import subprocess import shlex import time import argparse -import urllib.request -import urllib.error import contextlib import gzip import lzma @@ -75,6 +73,9 @@ from hate_crack.cli import ( # noqa: E402 setup_logging, ) from hate_crack import attacks as _attacks # noqa: E402 +from hate_crack import llm # noqa: E402 +from hate_crack import noninteractive as _noninteractive # noqa: E402 +from hate_crack.progress import spinner # noqa: E402 from hate_crack.menu import interactive_menu # noqa: E402 from hate_crack.username_detect import detect_username_hash_format # noqa: E402 @@ -361,6 +362,23 @@ else: hcatPotfilePath = os.path.join(hate_path, hcatPotfilePath) +def _normalize_ollama_url(host: str) -> str: + """Turn an ``OLLAMA_HOST`` value into a usable base URL. + + Ollama's own tooling accepts both a bare ``host:port`` and a full URL, so + accept either. Only prepend ``http://`` when no scheme is present; + unconditionally prepending it produced URLs like + ``http://https://ollama.example.com``. Trailing slashes are stripped + because callers append paths (``f"{ollamaUrl}/v1"``). + """ + host = (host or "").strip() + if not host: + return "http://localhost:11434" + if "://" not in host: + host = "http://" + host + return host.rstrip("/") + + def _maybe_append_username_flag(cmd): """Append --username if the active hash file has user:hash format and the flag isn't already present (from hcatTuning or elsewhere).""" @@ -450,15 +468,20 @@ hcatGoodMeasureBaseList = config_parser["hcatGoodMeasureBaseList"] hcatDebugLogPath = os.path.expanduser(config_parser["hcatDebugLogPath"]) -ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434") -ollamaModel = config_parser.get("ollamaModel", "mistral") +ollamaUrl = _normalize_ollama_url(os.environ.get("OLLAMA_HOST", "localhost:11434")) +ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b") ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048)) +ollamaTimeout = float(config_parser.get("ollamaTimeout", 300)) +ollamaMaxSampleLines = int(config_parser.get("ollamaMaxSampleLines", 500)) +ollamaAutoResearch = bool(config_parser.get("ollamaAutoResearch", True)) omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt") omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000)) pcfgRuleset = config_parser.get("pcfgRuleset", "DEFAULT") pcfgMaxCandidates = int(config_parser.get("pcfgMaxCandidates", 50000000)) -pcfgPrinceLingMaxCandidates = int(config_parser.get("pcfgPrinceLingMaxCandidates", 10000000)) +pcfgPrinceLingMaxCandidates = int( + config_parser.get("pcfgPrinceLingMaxCandidates", 10000000) +) try: _cfg_optimized = config_parser["optimizedKernelAttacks"] @@ -725,9 +748,15 @@ if not SKIP_INIT: # Verify pcfg_cracker presence (optional, for PCFG attacks) # pcfg_cracker is pure-Python; we just check the script files exist. pcfg_guesser_script = os.path.join(hate_path, "pcfg_cracker", "pcfg_guesser.py") - pcfg_prince_ling_script = os.path.join(hate_path, "pcfg_cracker", "prince_ling.py") - if not os.path.isfile(pcfg_guesser_script) or not os.path.isfile(pcfg_prince_ling_script): - print("pcfg_cracker not found at " + os.path.join(hate_path, "pcfg_cracker")) + pcfg_prince_ling_script = os.path.join( + hate_path, "pcfg_cracker", "prince_ling.py" + ) + if not os.path.isfile(pcfg_guesser_script) or not os.path.isfile( + pcfg_prince_ling_script + ): + print( + "pcfg_cracker not found at " + os.path.join(hate_path, "pcfg_cracker") + ) print("PCFG attacks will not be available. Run 'make' to fetch submodules.") elif not shutil.which("python3"): print("python3 not on PATH. PCFG attacks will not be available.") @@ -757,6 +786,7 @@ hcatGenerateRulesCount = 0 hcatPermuteCount = 0 hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False +non_interactive = False hcatUsernamePrefix: bool = False @@ -895,6 +925,23 @@ def _wordlist_path(path: str): yield path +def _usable_plaintext(raw: str) -> str: + """Return the usable plaintext from a raw wordlist line, or empty string. + + Blank/whitespace-only lines are discarded. Lines in ``hash:password`` + format (as produced by hashcat ``--show``) are split on the first colon + so only the plaintext portion is returned; lines with no colon are + returned as-is. A ``hash:`` line whose plaintext is empty after + stripping returns an empty string and is therefore also discarded. + """ + stripped = raw.strip() + if not stripped: + return "" + if ":" in stripped: + stripped = stripped.split(":", 1)[1] + return stripped + + def _add_debug_mode_for_rules(cmd): """Add debug mode arguments to hashcat command if rules are being used. @@ -1191,7 +1238,12 @@ def select_file_with_autocomplete( full_prompt += f" (default: {default})" full_prompt += ": " - result = input(full_prompt).strip() + try: + result = input(full_prompt).strip() + finally: + # Drop the path completer so later plain prompts (numeric menus, y/n) + # don't inherit stale file-path tab completion. + readline.set_completer(None) if not result and base_dir: result = base_dir @@ -2036,171 +2088,182 @@ def hcatBandrel(hcatHashType, hcatHashFile): _run_hcat_cmd(cmd, attack_name="Bandrel", hash_file=hcatHashFile) -# Pull an Ollama model via the /api/pull streaming endpoint -def _pull_ollama_model(url, model): - """Pull an Ollama model. Returns True on success, False on failure.""" - print(f"Model '{model}' not found locally. Pulling from Ollama...") - pull_url = f"{url}/api/pull" - payload = json.dumps({"name": model, "stream": True}).encode("utf-8") - req = urllib.request.Request( - pull_url, - data=payload, - headers={"Content-Type": "application/json"}, - ) +def _sample_plaintext_file(path, cap, source_label="wordlist"): + """Return an evenly-spaced sample of usable plaintexts from ``path``. + + ``cap`` is the maximum number of lines to keep (values <= 0 fall back to the + built-in default of 500). ``source_label`` is used only in the progress and + error messages so callers can say "wordlist" or "cracked passwords". + + Returns a list of plaintexts (possibly empty when the file has no usable + lines), or ``None`` if the file could not be read — in which case an error + has already been printed. + """ + # Two-pass evenly-spaced sample: first count usable lines so we can + # stride-select across the whole file rather than taking a head slice. + # A head-only sample misses the pattern variation across large wordlists + # (e.g. rockyou.txt becomes more random further in). try: - with urllib.request.urlopen(req) as resp: - for raw_line in resp: - line = raw_line.decode("utf-8", errors="replace").strip() - if not line: - continue - try: - data = json.loads(line) - except json.JSONDecodeError: - continue - status = data.get("status") - if status: - print(f" {status}") - except urllib.error.HTTPError as e: - print(f"Error pulling model: HTTP {e.code}") - return False - except urllib.error.URLError as e: - print(f"Error: Could not connect to Ollama: {e}") - return False + total_usable = 0 + with open(path, "r", errors="ignore") as f: + for raw in f: + if _usable_plaintext(raw): + total_usable += 1 except Exception as e: - print(f"Error pulling model: {e}") - return False - print(f"Successfully pulled model '{model}'.") - return True + print(f"Error reading {source_label}: {e}") + return None + + # Invalid cap (zero or negative): fall back to the built-in default of 500. + if cap <= 0: + cap = 500 + + if total_usable <= cap: + # No capping needed — collect all usable lines. + try: + sampled: list[str] = [] + with open(path, "r", errors="ignore") as f: + for raw in f: + w = _usable_plaintext(raw) + if w: + sampled.append(w) + except Exception as e: + print(f"Error reading {source_label}: {e}") + return None + print(f"Loaded {len(sampled):,} passwords from {source_label}.") + return sampled + + # Evenly-spaced sample: the k-th pick targets index floor(k * total / cap), + # which yields EXACTLY cap distinct indices spanning the full range for any + # 1 <= cap <= total_usable. + try: + pick_set = {(k * total_usable) // cap for k in range(cap)} + sampled = [] + usable_idx = 0 + with open(path, "r", errors="ignore") as f: + for raw in f: + w = _usable_plaintext(raw) + if not w: + continue + if usable_idx in pick_set: + sampled.append(w) + usable_idx += 1 + except Exception as e: + print(f"Error reading {source_label}: {e}") + return None + print( + f"Sampled {len(sampled):,} of {total_usable:,} passwords from {source_label}." + ) + return sampled + + +def hcatOllamaResearchTarget(company): + """Ask the local Ollama model what it knows about *company*. + + Returns a dict with "industry" and "location" keys; either value may be an + empty string when the model is not confident or the request failed. Never + raises: research is a convenience, so any failure degrades to empty + suggestions (blank prompts) rather than blocking the attack. + + Uses only the configured local Ollama server — the company name is never + sent to a third-party service. + """ + blank = {"industry": "", "location": ""} + if not ollamaAutoResearch or not company: + return blank + + try: + with spinner(f"Researching {company} via Ollama ({ollamaModel})..."): + result = llm.research_target( + ollamaUrl, + ollamaModel, + ollamaNumCtx, + company, + timeout=ollamaTimeout, + ) + except llm.LLMTimeoutError: + print( + f"Note: target research timed out after {ollamaTimeout:g} seconds — " + "enter the details manually." + ) + return blank + except Exception as e: + print(f"Note: target research unavailable ({e}) — enter the details manually.") + return blank + + return {"industry": result.industry, "location": result.location} # LLM Ollama Attack def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): - global hcatProcess candidates_path = f"{hcatHashFile}.ollama_candidates" - # Step A: Build LLM prompt based on mode + # Step A: normalize context into the dict generate_candidates expects. if mode == "wordlist": wordlist_path = context_data if not os.path.isfile(wordlist_path): print(f"Error: Wordlist not found: {wordlist_path}") return - lines = [] - try: - with open(wordlist_path, "r", errors="ignore") as f: - for line in f: - stripped = line.strip() - if not stripped: - continue - # Use only content after the first colon (e.g. hash:password -> password) - if ":" in stripped: - stripped = stripped.split(":", 1)[1] - if stripped: - lines.append(stripped) - except Exception as e: - print(f"Error reading wordlist: {e}") + + sampled = _sample_plaintext_file(wordlist_path, ollamaMaxSampleLines) + if sampled is None: return - print(f"Loaded {len(lines)} passwords from wordlist.") - wordlist_sample = "\n".join(lines) - prompt = ( - "Generate baseword to be used in a denylist for keeping users from setting their passwords with these basewords." - "Study the patterns, character choices, and structures. Focus on patterns like capitalization, leetspeak, suffixes, and common substitutions. Here are the sample passwords:\n" - f"{wordlist_sample}" + gen_context = {"sample": "\n".join(sampled)} + elif mode == "cracked": + # context_data may carry an explicit path; default to this session's + # cracked-output file. + cracked_path = context_data or f"{hcatHashFile}.out" + if not os.path.isfile(cracked_path): + print(f"Error: No cracked passwords found: {cracked_path}") + return + + sampled = _sample_plaintext_file( + cracked_path, ollamaMaxSampleLines, source_label="cracked passwords" ) + if sampled is None: + return + if not sampled: + print( + "Error: No cracked passwords yet — crack some hashes first, then " + "use this mode to generate more candidates in the same style." + ) + return + gen_context = {"sample": "\n".join(sampled)} elif mode == "target": - company = context_data.get("company", "") - industry = context_data.get("industry", "") - location = context_data.get("location", "") - prompt = ( - "You are participating in a capture the flag event as a security professional. " - "You are my partner in the competition. You need to recover the password to a system to retrieve the flag. " - "Output as many possible password combinations you think might help us. " - f"The name of the fake company is {company}. They are a {industry} in {location}. " - "Use terms related to the industry as basewords and also use permutations of the company name combined with common suffixes. " - "Only output the candidate password each on a new line. Dont output any explanation. " - "Only output the password candidate. Do not number the lines or add any extra information to the output" - ) + gen_context = context_data else: print(f"Error: Unknown LLM generation mode: {mode}") return - # Step B: Call Ollama API to generate candidates - print(f"Generating password candidates via Ollama ({ollamaModel})...") - api_url = f"{ollamaUrl}/api/generate" - payload = json.dumps( - { - "model": ollamaModel, - "prompt": prompt, - "stream": False, - "options": {"num_ctx": ollamaNumCtx}, - } - ).encode("utf-8") - - if debug_mode: - print(f"[DEBUG] Ollama API URL: {api_url}") - print(f"[DEBUG] Ollama request payload: {payload.decode('utf-8')}") - + # Step B: generate candidates via the Atomic Agents module. try: - req = urllib.request.Request( - api_url, - data=payload, - headers={"Content-Type": "application/json"}, + with spinner(f"Generating password candidates via Ollama ({ollamaModel})..."): + candidates = llm.generate_candidates( + ollamaUrl, + ollamaModel, + ollamaNumCtx, + mode, + gen_context, + timeout=ollamaTimeout, + ) + except llm.LLMTimeoutError: + print(f"Error: the Ollama request timed out after {ollamaTimeout:g} seconds.") + print( + f"The model ({ollamaModel}) may still be loading into VRAM. Retry, or " + 'raise "ollamaTimeout" in config.json to wait longer.' ) - with urllib.request.urlopen(req, timeout=600) as resp: - result = json.loads(resp.read().decode("utf-8")) - if debug_mode: - print(f"[DEBUG] Ollama response: {json.dumps(result, indent=2)}") - except urllib.error.HTTPError as e: - if e.code == 404: - if _pull_ollama_model(ollamaUrl, ollamaModel): - try: - req = urllib.request.Request( - api_url, - data=payload, - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=600) as resp: - result = json.loads(resp.read().decode("utf-8")) - if debug_mode: - print( - f"[DEBUG] Ollama response (after pull): {json.dumps(result, indent=2)}" - ) - except Exception as retry_err: - print(f"Error calling Ollama API after pull: {retry_err}") - return - else: - print(f"Could not pull model '{ollamaModel}'. Aborting LLM attack.") - return - else: - print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") - print("Ensure Ollama is running (ollama serve) and try again.") - return - except urllib.error.URLError as e: - print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") - print("Ensure Ollama is running (ollama serve) and try again.") + return + except ValueError as e: + # Defensive: mode is already validated above, but keep an explicit, + # non-misleading message if generate_candidates ever rejects its input. + print(f"Error: {e}") return except Exception as e: - print(f"Error calling Ollama API: {e}") - return - - response_text = result.get("response", "") - if "I'm sorry, but I can't help with that" in response_text: + print(f"Error generating candidates: {e}") print( - "Error: Ollama refused the request. Try a different model or adjust your prompt." + "Ensure Ollama is running (ollama serve) and the model is pulled " + f"(ollama pull {ollamaModel})." ) return - raw_lines = response_text.strip().split("\n") - # Filter out blank lines and lines that look like numbering/explanation - candidates = [] - for line in raw_lines: - stripped = line.strip() - if not stripped: - continue - # Strip leading numbering like "1. " or "1) " or "- " - cleaned = re.sub(r"^\d+[.)]\s*", "", stripped) - cleaned = re.sub(r"^[-*]\s*", "", cleaned) - cleaned = cleaned.strip() - if cleaned and len(cleaned) <= 128: - candidates.append(cleaned) if not candidates: print("Error: Ollama returned no usable password candidates.") @@ -2215,13 +2278,8 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): return print(f"Generated {len(candidates)} password candidates -> {candidates_path}") - if debug_mode: - filtered_count = len(raw_lines) - len(candidates) - print( - f"[DEBUG] Filtered out {filtered_count} lines from Ollama response ({len(raw_lines)} raw -> {len(candidates)} candidates)" - ) - # Step C: Run hashcat wordlist attack with LLM-generated candidates (no rules) + # Step C: hashcat wordlist attack with the generated candidates (no rules). print("Running wordlist attack with LLM-generated candidates...") cmd = [ hcatBin, @@ -2246,7 +2304,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): except KeyboardInterrupt: return - # Step D: Run hashcat with LLM candidates against every rule in the rules directory + # Step D: hashcat with candidates against every rule in the rules directory. rule_files = sorted(f for f in os.listdir(rulesDirectory) if f != ".DS_Store") if not rule_files: print("No rule files found in rules directory. Skipping rule-based attacks.") @@ -2773,8 +2831,11 @@ def hcatPrinceLing(hcatHashType, hcatHashFile): print(f"PCFG ruleset not found: {ruleset_dir}") return - cache_dir = hcatOptimizedWordlists if isinstance(hcatOptimizedWordlists, str) \ + cache_dir = ( + hcatOptimizedWordlists + if isinstance(hcatOptimizedWordlists, str) else str(hcatOptimizedWordlists) + ) os.makedirs(cache_dir, exist_ok=True) cache_path = os.path.join(cache_dir, f"pcfg_prince_ling_{pcfgRuleset}.txt") tmp_path = cache_path + ".tmp" @@ -3619,9 +3680,7 @@ def hashview_api(): or api_name ) try: - download_result = api_harness.download_rules( - rules_id, output_file - ) + download_result = api_harness.download_rules(rules_id, output_file) print(f"\n✓ Success: Downloaded {download_result['size']} bytes") print(f" File: {download_result['output_file']}") except Exception as e: @@ -3891,8 +3950,8 @@ def hashview_api(): print( "\nScanning customer hashfiles across common hash types..." ) - customer_hashfiles = ( - api_harness.get_all_customer_hashfiles(customer_id) + customer_hashfiles = api_harness.get_all_customer_hashfiles( + customer_id ) except Exception as e: customer_hashfiles = [] @@ -4032,6 +4091,15 @@ def hashview_api(): print(f"\nError connecting to Hashview: {str(e)}") +def _auto_input(prompt, default=""): + """input() wrapper that returns the default without prompting when running + in non-interactive (scripted) mode. In interactive mode this is identical + to ``input(prompt) or default``.""" + if non_interactive: + return default + return input(prompt) or default + + def _attack_ctx(): ctx = sys.modules.get(__name__) if ctx is None: @@ -4375,15 +4443,18 @@ def pipal(): pipalFile.write(clearTextPass) pipalFile.close() - pipalProcess = subprocess.Popen( - "{pipal_path} {pipal_file} -t {pipal_count} --output {pipal_out}".format( - pipal_path=pipalPath, - pipal_file=hcatHashFilePipal + ".passwords", - pipal_out=hcatHashFilePipal + ".pipal", - pipal_count=pipal_count, - ), - shell=True, - ) + # List-form Popen (no shell=True) so paths/filenames containing + # shell metacharacters can't be interpreted as commands. shlex.split + # on pipalPath still allows an interpreter prefix (e.g. "ruby + # /opt/pipal/pipal.rb") to be configured. + pipal_cmd = shlex.split(pipalPath) + [ + hcatHashFilePipal + ".passwords", + "-t", + str(pipal_count), + "--output", + hcatHashFilePipal + ".pipal", + ] + pipalProcess = subprocess.Popen(pipal_cmd) try: pipalProcess.wait() except KeyboardInterrupt: @@ -4406,25 +4477,31 @@ def pipal(): print(pipalfile.read()) print("\n--- Pipal Output End ---\n") with open(hcatHashFilePipal + ".pipal") as pipalfile: - pipal_content = pipalfile.readlines() - raw_pipal = "\n".join(pipal_content) - raw_pipal = re.sub("\n+", "\n", raw_pipal) - raw_regex = r"Top [0-9]+ base words\n" - for word in range(pipal_count): - raw_regex += r"(\S+).*\n" - basewords_re = re.compile(raw_regex) - results = re.search(basewords_re, raw_pipal) + pipal_content = pipalfile.read() + # Parse the "Top N base words" section line by line rather than + # with one rigid regex. The old approach required *exactly* + # pipal_count baseword lines, so any cracked set with fewer + # unique base words than pipal_count (the common case on small + # cracks) matched nothing and returned []. Collect up to + # pipal_count base words and stop at the end of the section. top_basewords = [] - if results: - if results.lastindex is not None: - for i in range(1, results.lastindex + 1): - if i is not None: - top_basewords.append(results.group(i)) - else: - pass - return top_basewords - else: - return [] + in_section = False + for line in pipal_content.splitlines(): + if re.match(r"\s*Top\s+[0-9]+\s+base words", line): + in_section = True + continue + if in_section: + if not line.strip(): + # blank line terminates the base words section + break + # Capture the base word (first token); tolerate both + # "word = 5 (5%)" and "word 5" separators. + match = re.match(r"\s*(\S+)", line) + if match: + top_basewords.append(match.group(1)) + if len(top_basewords) >= pipal_count: + break + return top_basewords else: print("No hashes were cracked :(") return [] @@ -4703,6 +4780,7 @@ def main(): global hcatHashFileOrig global lmHashesFound global debug_mode + global non_interactive global hashview_url, hashview_api_key global hcatPath, hcatBin, hcatWordlists, hcatOptimizedWordlists, rulesDirectory global pipalPath, maxruntime, bandrelbasewords @@ -4712,6 +4790,7 @@ def main(): # Initialize global variables hcatHashFile = None + non_interactive = False hcatHashType = None hcatHashFileOrig = None @@ -4798,6 +4877,7 @@ def main(): return parser, hashview_parser subparsers = parser.add_subparsers(dest="command") + _noninteractive.add_attack_subparsers(subparsers) hashview_parser = subparsers.add_parser( "hashview", help="Hashview menu actions" @@ -4924,7 +5004,8 @@ def main(): else: argv = argv_temp # Fallback if subcommand not found - use_subcommand_parser = "hashview" in argv + has_attack_subcommand = any(arg in _noninteractive.ATTACK_COMMANDS for arg in argv) + use_subcommand_parser = "hashview" in argv or has_attack_subcommand parser, hashview_parser = _build_parser( include_positional=not use_subcommand_parser, include_subcommands=use_subcommand_parser, @@ -4933,6 +5014,8 @@ def main(): global debug_mode debug_mode = args.debug + if getattr(args, "command", None) in _noninteractive.ATTACK_COMMANDS: + non_interactive = True # CLI flags override config file. if getattr(args, "no_potfile_path", False): @@ -5231,8 +5314,8 @@ def main(): f"Detected {computer_count} computer account(s)" " (usernames ending with $)." ) - filter_choice = ( - input("Would you like to ignore computer accounts? (Y) ") or "Y" + filter_choice = _auto_input( + "Would you like to ignore computer accounts? (Y) ", "Y" ) if filter_choice.upper() == "Y": filtered_path = f"{hcatHashFile}.filtered" @@ -5254,12 +5337,10 @@ def main(): ) ) or (lineCount(hcatHashFile + ".lm") > 1): lmHashesFound = True - lmChoice = ( - input( - "LM hashes identified. Would you like to brute force" - " the LM hashes first? (Y) " - ) - or "Y" + lmChoice = _auto_input( + "LM hashes identified. Would you like to brute force" + " the LM hashes first? (Y) ", + "Y", ) if lmChoice.upper() == "Y": hcatLMtoNT() @@ -5305,8 +5386,8 @@ def main(): f"Detected {computer_count} computer account(s)" " (usernames ending with $)." ) - filter_choice = ( - input("Would you like to ignore computer accounts? (Y) ") or "Y" + filter_choice = _auto_input( + "Would you like to ignore computer accounts? (Y) ", "Y" ) if filter_choice.upper() == "Y": filtered_path = f"{hcatHashFile}.filtered" @@ -5329,12 +5410,10 @@ def main(): f"Detected {duplicates} duplicate account(s) out of" f" {total} total NetNTLM hashes." ) - dedup_choice = ( - input( - "Would you like to ignore duplicate accounts" - " (keep first occurrence only)? (Y) " - ) - or "Y" + dedup_choice = _auto_input( + "Would you like to ignore duplicate accounts" + " (keep first occurrence only)? (Y) ", + "Y", ) if dedup_choice.upper() == "Y": hcatHashFileOrig = hcatHashFile @@ -5385,6 +5464,9 @@ def main(): else: print("No hashes found in POT file.") + if non_interactive: + sys.exit(_noninteractive.run_noninteractive(_attack_ctx(), args)) + # Display Options try: options = get_main_menu_options() diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py new file mode 100644 index 0000000..18a23bb --- /dev/null +++ b/hate_crack/noninteractive.py @@ -0,0 +1,145 @@ +"""Non-interactive (scripted) attack entry points for hate_crack. + +These helpers translate parsed argparse namespaces into calls against the +existing ``hcat*`` attack functions on the main module (passed in as ``ctx``, +the same pattern ``attacks.py`` uses). See +``docs/superpowers/specs/2026-07-24-cli-noninteractive-design.md``. +""" + +import os +from typing import Any + +ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") + + +def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: + """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. + + Each token becomes one attack pass. A token may chain multiple rule files + with ``+`` (mirroring the interactive rule selector). Filenames resolve + against ``ctx.rulesDirectory``. Returns ``[""]`` when no rules are given + (equivalent to the interactive "run without rules" choice). + + Raises ``FileNotFoundError`` (with the offending filename as its argument) + if any named rule file is missing. + """ + if not rule_tokens: + return [""] + chains = [] + for token in rule_tokens: + chain = "" + for name in token.split("+"): + name = name.strip() + if not name: + continue + path = os.path.join(ctx.rulesDirectory, name) + if not os.path.isfile(path): + raise FileNotFoundError(name) + chain = f"{chain} -r {path}".strip() + if not chain: + raise ValueError(f"Rule token {token!r} resolved to no rule files") + chains.append(chain) + return chains + + +def run_noninteractive(ctx: Any, args: Any) -> int: + """Run a non-interactive attack. Returns a process exit code. + + ``ctx`` is the main module (live ``hcat*`` functions, ``rulesDirectory``, + ``resolve_path``, ``hcatHashType``/``hcatHashFile`` already set by the + preprocessing block in ``main()``). ``args`` is the parsed subparser + namespace whose ``command`` selects the attack. + """ + command = args.command + + if command == "quick": + wordlist = ctx.resolve_path(args.wordlist) + if not wordlist or not os.path.isfile(wordlist): + print(f"Error: wordlist not found: {args.wordlist}") + return 1 + try: + chains = build_rule_chains(ctx, args.rule_files) + except (FileNotFoundError, ValueError) as exc: + print(f"Error: invalid --rules value: {exc}") + return 1 + for chain in chains: + ctx.hcatQuickDictionary( + ctx.hcatHashType, + ctx.hcatHashFile, + chain, + wordlist, + attack_name="Quick Crack", + ) + return 0 + + if command == "dict": + ctx.hcatDictionary(ctx.hcatHashType, ctx.hcatHashFile) + return 0 + + if command == "brute": + ctx.hcatBruteForce( + ctx.hcatHashType, ctx.hcatHashFile, args.min_len, args.max_len + ) + return 0 + + if command == "topmask": + ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, args.target_time * 3600) + return 0 + + print(f"Error: unknown non-interactive command: {command}") + return 2 + + +def add_attack_subparsers(subparsers) -> None: + """Register the non-interactive attack subcommands on an argparse + subparsers object (the same one used for ``hashview``). + + Each subcommand carries its own required ``hashfile`` + ``hashtype`` + positionals plus attack-specific flags. + """ + + def _add_target(p): + p.add_argument("hashfile", help="Path to hash file to crack") + p.add_argument("hashtype", help="Hashcat hash type (e.g. 1000 for NTLM)") + + quick = subparsers.add_parser( + "quick", help="Non-interactive quick crack (single wordlist + optional rules)" + ) + _add_target(quick) + quick.add_argument("--wordlist", required=True, help="Path to wordlist file") + quick.add_argument( + "--rules", + nargs="*", + default=[], + dest="rule_files", + metavar="RULE", + help="Rule filename(s) from the rules directory. Chain with '+' " + "(e.g. best64.rule+d3ad0ne.rule). Omit to run without rules.", + ) + + dictp = subparsers.add_parser( + "dict", + help="Non-interactive dictionary methodology (uses configured wordlists)", + ) + _add_target(dictp) + + brute = subparsers.add_parser( + "brute", help="Non-interactive brute force (mask) attack" + ) + _add_target(brute) + brute.add_argument( + "--min", type=int, default=1, dest="min_len", help="Minimum length (default 1)" + ) + brute.add_argument( + "--max", type=int, default=7, dest="max_len", help="Maximum length (default 7)" + ) + + topmask = subparsers.add_parser("topmask", help="Non-interactive top-mask attack") + _add_target(topmask) + topmask.add_argument( + "--target-time", + type=int, + default=4, + dest="target_time", + help="Target completion time in hours (default 4)", + ) diff --git a/hate_crack/progress.py b/hate_crack/progress.py new file mode 100644 index 0000000..cc63d54 --- /dev/null +++ b/hate_crack/progress.py @@ -0,0 +1,71 @@ +"""Generic terminal progress utilities for hate_crack. + +Provides a context manager that shows a live spinner with elapsed-seconds +counter while a blocking operation runs. Safe to use in non-TTY environments: +if stdout is not a TTY the message is printed once and no background thread is +started. +""" + +from __future__ import annotations + +import sys +import threading +import time +from collections.abc import Generator +from contextlib import contextmanager + +_SPINNER_FRAMES = ["|", "/", "-", "\\"] +_TICK_INTERVAL = 0.12 # seconds between repaints (~120 ms) + + +@contextmanager +def spinner(message: str) -> "Generator[None, None, None]": + """Context manager that shows *message* plus a live elapsed-seconds counter. + + While the body executes a daemon thread repaints a single terminal line + roughly every 120 ms showing: + + | Generating password candidates via Ollama (model)... 3s + + On exit (normal or exceptional) the spinner line is erased so subsequent + output starts on a clean line. + + TTY guard: if ``sys.stdout.isatty()`` is False the message is printed once + via ``print()`` and no thread is started, keeping piped output and the test + suite clean. + """ + if not sys.stdout.isatty(): + print(message) + yield + return + + stop_event = threading.Event() + start_time = time.monotonic() + + def _run() -> None: + frame_idx = 0 + while not stop_event.is_set(): + elapsed = int(time.monotonic() - start_time) + frame = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)] + line = f"\r{frame} {message} {elapsed}s" + sys.stdout.write(line) + sys.stdout.flush() + frame_idx += 1 + stop_event.wait(_TICK_INTERVAL) + + thread = threading.Thread(target=_run, daemon=True) + thread.start() + try: + yield + finally: + stop_event.set() + # Clear *after* the join, so a thread sitting just past its is_set() + # check cannot repaint the line after we erase it. The nested finally + # keeps that guarantee even when join() is interrupted: hate_crack's + # _sigint_handler raises DoubleInterrupt on a second SIGINT within 2 s, + # and join() can block up to _TICK_INTERVAL (0.12 s). + try: + thread.join() + finally: + sys.stdout.write("\033[2K\r") + sys.stdout.flush() diff --git a/hate_crack/username_detect.py b/hate_crack/username_detect.py index f334e5f..7ea8038 100644 --- a/hate_crack/username_detect.py +++ b/hate_crack/username_detect.py @@ -3,6 +3,7 @@ This module owns the allowlist/blocklist of hash modes and the regex-based per-line validation used to decide whether to pass ``--username`` to hashcat. """ + from __future__ import annotations import re @@ -11,39 +12,59 @@ from typing import Final # Modes where bare hashes are the normal input AND files commonly carry a # ``username:`` prefix. Value is the expected hex length of the hash field. USERNAME_HASH_MODES: Final[dict[str, int]] = { - "0": 32, # MD5 - "10": 32, # md5($pass.$salt) - "20": 32, # md5($salt.$pass) - "30": 32, # md5(unicode($pass).$salt) - "40": 32, # md5($salt.unicode($pass)) - "50": 32, # HMAC-MD5 - "60": 32, # HMAC-MD5(key=$pass) - "100": 40, # SHA1 - "101": 40, # nsldap/SHA1(Base64) - "110": 40, # sha1($pass.$salt) - "120": 40, # sha1($salt.$pass) - "130": 40, # sha1(unicode($pass).$salt) - "140": 40, # sha1($salt.unicode($pass)) - "150": 40, # HMAC-SHA1 - "160": 40, # HMAC-SHA1(key=$pass) - "900": 32, # MD4 - "1000": 32, # NTLM bare - "1400": 64, # SHA2-256 - "1410": 64, "1420": 64, "1430": 64, "1440": 64, "1450": 64, "1460": 64, + "0": 32, # MD5 + "10": 32, # md5($pass.$salt) + "20": 32, # md5($salt.$pass) + "30": 32, # md5(unicode($pass).$salt) + "40": 32, # md5($salt.unicode($pass)) + "50": 32, # HMAC-MD5 + "60": 32, # HMAC-MD5(key=$pass) + "100": 40, # SHA1 + "101": 40, # nsldap/SHA1(Base64) + "110": 40, # sha1($pass.$salt) + "120": 40, # sha1($salt.$pass) + "130": 40, # sha1(unicode($pass).$salt) + "140": 40, # sha1($salt.unicode($pass)) + "150": 40, # HMAC-SHA1 + "160": 40, # HMAC-SHA1(key=$pass) + "900": 32, # MD4 + "1000": 32, # NTLM bare + "1400": 64, # SHA2-256 + "1410": 64, + "1420": 64, + "1430": 64, + "1440": 64, + "1450": 64, + "1460": 64, "1700": 128, # SHA2-512 - "1710": 128, "1720": 128, "1730": 128, "1740": 128, "1750": 128, "1760": 128, - "3000": 16, # LM (single half) + "1710": 128, + "1720": 128, + "1730": 128, + "1740": 128, + "1750": 128, + "1760": 128, + "3000": 16, # LM (single half) } # Modes explicitly excluded even if they appear in allowlist (they don't, but # this constant is the documentation of the intentional blocklist). Binary # formats, IKE-PSK, and NetNTLM (already preprocessed elsewhere). -USERNAME_DETECT_BLOCKLIST: Final[frozenset[str]] = frozenset({ - "2500", "22000", "2501", "16800", "16801", "22001", # WPA variants (binary) - "5300", "5400", # IKE-PSK - "5500", "5600", # NetNTLM (own preprocess) - "1800", "3200", # non-hex hash formats -}) +USERNAME_DETECT_BLOCKLIST: Final[frozenset[str]] = frozenset( + { + "2500", + "22000", + "2501", + "16800", + "16801", + "22001", # WPA variants (binary) + "5300", + "5400", # IKE-PSK + "5500", + "5600", # NetNTLM (own preprocess) + "1800", + "3200", # non-hex hash formats + } +) def detect_username_hash_format( diff --git a/prek.toml b/prek.toml index f529529..9ce02d2 100644 --- a/prek.toml +++ b/prek.toml @@ -10,6 +10,15 @@ stages = ["pre-push"] pass_filenames = false always_run = true +[[repos.hooks]] +id = "ruff-format" +name = "ruff-format" +entry = "uv run ruff format --check hate_crack" +language = "system" +stages = ["pre-push"] +pass_filenames = false +always_run = true + [[repos.hooks]] id = "ty" name = "ty" @@ -38,10 +47,38 @@ pass_filenames = false always_run = true [[repos.hooks]] -id = "audit-docs" -name = "audit-docs" -entry = "bash .claude/audit-docs.sh HEAD" +id = "bandit" +name = "bandit security scan (vs baseline)" +entry = "uvx --from 'bandit[toml]==1.9.4' bandit -r hate_crack -c pyproject.toml -b .bandit-baseline.json -q" language = "system" -stages = ["post-commit"] +stages = ["pre-push"] pass_filenames = false always_run = true + +# General hygiene hooks (mirrors hashview's .pre-commit-config.yaml). These run +# at the pre-commit stage, so `prek install` must include `--hook-type pre-commit`. +# Auto-fixers modify files in place; re-stage and commit again. +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v5.0.0" + +[[repos.hooks]] +id = "trailing-whitespace" + +[[repos.hooks]] +id = "end-of-file-fixer" + +[[repos.hooks]] +id = "check-yaml" + +[[repos.hooks]] +id = "check-merge-conflict" + +[[repos.hooks]] +id = "check-added-large-files" +args = ["--maxkb=1024"] + +# Blocks committing PEM/OpenSSH private keys. The repo had no secret-scanning +# gate at all before this; bandit only covers hate_crack/. +[[repos.hooks]] +id = "detect-private-key" diff --git a/pyproject.toml b/pyproject.toml index 49104bd..47cd663 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=69", "setuptools-scm>=8"] +requires = ["setuptools>=83.0.0", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] @@ -9,12 +9,18 @@ description = "Menu driven Python wrapper for hashcat" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "requests>=2.31.0", - "beautifulsoup4>=4.12.0", + "requests>=2.34.2", + "beautifulsoup4>=4.15.0", "openpyxl>=3.0.0", - "packaging>=21.0", + "packaging>=26.2", "simple-term-menu==1.6.6", - "click>=8.0.0", + "click>=8.4.2", + "atomic-agents>=2.0.0", + # Imported directly by hate_crack/llm.py; declared explicitly rather than + # relying on atomic-agents pulling them in transitively. + "instructor>=1.14.5", + "openai>=2.48.0", + "pydantic>=2.13.4", ] [project.scripts] @@ -44,6 +50,23 @@ exclude = [ "rules", ] +[tool.bandit] +# hate_crack shells out to hashcat and its helper binaries extensively, so the +# scan produces many low-severity subprocess/shlex findings. These are reviewed +# and captured in .bandit-baseline.json; CI compares against that baseline so +# only NEW findings fail the build (mirrors the hashview approach). Scan the +# first-party package only — bundled third-party trees are excluded. +exclude_dirs = [ + "tests", + ".venv", + "build", + "dist", + "PACK", + "hashcat-utils", + "omen", + "princeprocessor", +] + [tool.ty.src] exclude = [ "build/", @@ -75,6 +98,6 @@ dev = [ "ty==0.0.17", "ruff==0.16.0", "pytest==9.0.3", - "pytest-cov==7.0.0", + "pytest-cov==7.1.0", "pytest-timeout>=2.4.0", ] diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index 4096819..5436dba 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -329,7 +329,10 @@ class TestOllamaAttack: def test_calls_hcatOllama_with_context(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["ACME", "tech", "NYC"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["ACME", "tech", "NYC"]), + ): ollama_attack(ctx) ctx.hcatOllama.assert_called_once_with( @@ -342,7 +345,10 @@ class TestOllamaAttack: def test_passes_hash_type_and_file(self) -> None: ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt") - with patch("builtins.input", side_effect=["Corp", "finance", "London"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["Corp", "finance", "London"]), + ): ollama_attack(ctx) call_args = ctx.hcatOllama.call_args[0] @@ -352,7 +358,10 @@ class TestOllamaAttack: def test_strips_whitespace_from_inputs(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]), + ): ollama_attack(ctx) target_info = ctx.hcatOllama.call_args[0][3] @@ -363,7 +372,247 @@ class TestOllamaAttack: def test_target_string_is_literal_target(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["X", "Y", "Z"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["X", "Y", "Z"]), + ): ollama_attack(ctx) assert ctx.hcatOllama.call_args[0][2] == "target" + + def test_wordlist_mode_calls_hcatOllama_with_path(self) -> None: + ctx = _make_ctx() + ctx.list_wordlist_files.return_value = ["rockyou.txt"] + ctx.hcatWordlists = "/tmp/wl" + + # mode "2" from interactive_menu, then pick wordlist "1" via input + with ( + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["1"]), + ): + ollama_attack(ctx) + + args = ctx.hcatOllama.call_args[0] + assert args[2] == "wordlist" + assert args[3].endswith("rockyou.txt") + + def test_escape_cancels_without_calling_hcatOllama(self) -> None: + """None from interactive_menu (Escape / 99) cancels the attack.""" + ctx = _make_ctx() + with patch("hate_crack.attacks.interactive_menu", return_value=None): + ollama_attack(ctx) + ctx.hcatOllama.assert_not_called() + + def test_cancel_key_cancels_without_calling_hcatOllama(self) -> None: + """Selecting '99' (Cancel) cancels the attack.""" + ctx = _make_ctx() + with patch("hate_crack.attacks.interactive_menu", return_value="99"): + ollama_attack(ctx) + ctx.hcatOllama.assert_not_called() + + def test_wordlist_mode_aborts_when_no_wordlist_picked(self) -> None: + ctx = _make_ctx() + # mode "2" from interactive_menu, then user cancels the file picker + ctx.list_wordlist_files.return_value = [] + with ( + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["q"]), + ): + ollama_attack(ctx) + ctx.hcatOllama.assert_not_called() + + def test_cracked_mode_offered_when_out_file_has_content( + self, tmp_path: Path + ) -> None: + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("hash:Summer2024!\n") + ctx = _make_ctx(hash_file=str(hash_file)) + + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "3" + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): + ollama_attack(ctx) + + # Option 3 must be present in the items list when cracked file exists + keys = [k for k, _ in captured_items[0]] + assert "3" in keys + ctx.hcatOllama.assert_called_once_with( + ctx.hcatHashType, str(hash_file), "cracked", str(out_file) + ) + + def test_cracked_mode_not_offered_when_out_file_missing( + self, tmp_path: Path + ) -> None: + """Option 3 must NOT appear in items when no cracked file exists.""" + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + ctx = _make_ctx(hash_file=str(hash_file)) + + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "99" # cancel + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): + ollama_attack(ctx) + + keys = [k for k, _ in captured_items[0]] + assert "3" not in keys + ctx.hcatOllama.assert_not_called() + + def test_cracked_mode_not_offered_when_out_file_empty( + self, tmp_path: Path + ) -> None: + """Option 3 must NOT appear in items when cracked file exists but is empty.""" + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + (tmp_path / "hashes.txt.out").touch() # exists but zero bytes + ctx = _make_ctx(hash_file=str(hash_file)) + + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "99" # cancel + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): + ollama_attack(ctx) + + keys = [k for k, _ in captured_items[0]] + assert "3" not in keys + ctx.hcatOllama.assert_not_called() + + def test_target_and_wordlist_modes_unaffected_by_cracked_option( + self, tmp_path: Path + ) -> None: + """Existing modes still work when a cracked file is present.""" + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + (tmp_path / "hashes.txt.out").write_text("hash:Summer2024!\n") + ctx = _make_ctx(hash_file=str(hash_file)) + + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["ACME", "tech", "NYC"]), + ): + ollama_attack(ctx) + + assert ctx.hcatOllama.call_args[0][2] == "target" + + def test_arrow_menu_env_reaches_ollama_attack(self) -> None: + """HATE_CRACK_ARROW_MENU=1 routes through interactive_menu in ollama_attack.""" + import os + from hate_crack.attacks import interactive_menu as real_im + + ctx = _make_ctx() + calls: list[tuple] = [] + + def spy_menu(items, **kwargs): + calls.append(tuple(items)) + return "99" # cancel immediately + + with ( + patch("hate_crack.attacks.interactive_menu", side_effect=spy_menu), + ): + ollama_attack(ctx) + + # interactive_menu was called — confirms the arrow-menu code path is reachable + assert len(calls) == 1 + keys = [k for k, _ in calls[0]] + assert "1" in keys + assert "2" in keys + assert "99" in keys + + +class TestOmenPickTrainingWordlistReprompt: + """_pick_training_wordlist re-prompts on invalid input instead of aborting.""" + + def _make_ctx(self, wordlist_files=None): + ctx = MagicMock() + ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"] + ctx.hcatWordlists = "/tmp/wl" + ctx.hcatHashFile = "/tmp/hashes.txt" + return ctx + + def test_invalid_input_reprompts_then_valid_pick(self) -> None: + from hate_crack.attacks import _pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + # First input is invalid, second is valid + with patch("builtins.input", side_effect=["bad", "1"]): + result = _pick_training_wordlist(ctx) + assert result is not None + assert "rockyou.txt" in result + + def test_cancel_with_q_returns_none(self) -> None: + from hate_crack.attacks import _pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + with patch("builtins.input", return_value="q"): + result = _pick_training_wordlist(ctx) + assert result is None + + def test_multiple_invalid_inputs_then_cancel(self) -> None: + from hate_crack.attacks import _pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + with patch("builtins.input", side_effect=["99", "abc", "q"]): + result = _pick_training_wordlist(ctx) + assert result is None + + +class TestMarkovPickTrainingSourceReprompt: + """_markov_pick_training_source re-prompts on invalid input instead of aborting.""" + + def _make_ctx(self, tmp_path, has_cracked=False, wordlist_files=None): + ctx = MagicMock() + hash_file = str(tmp_path / "hashes.txt") + ctx.hcatHashFile = hash_file + ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"] + ctx.hcatWordlists = str(tmp_path / "wordlists") + if has_cracked: + (tmp_path / "hashes.txt.out").write_text("cracked_pw\n") + return ctx + + def test_invalid_input_reprompts_then_valid_pick(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + with patch("builtins.input", side_effect=["bad", "1"]): + result = _markov_pick_training_source(ctx) + assert result is not None + assert "rockyou.txt" in result + + def test_cancel_with_q_returns_none(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path) + with patch("builtins.input", return_value="q"): + result = _markov_pick_training_source(ctx) + assert result is None + + def test_multiple_invalid_then_cancel(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + with patch("builtins.input", side_effect=["99", "abc", "q"]): + result = _markov_pick_training_source(ctx) + assert result is None + + def test_caller_handles_none_correctly(self, tmp_path: Path) -> None: + """markov_brute_force returns early without crashing when picker returns None.""" + from hate_crack.attacks import markov_brute_force + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + # No .hcstat2 file → goes straight to picker; user cancels + with patch("builtins.input", return_value="q"): + markov_brute_force(ctx) + ctx.hcatMarkovTrain.assert_not_called() + ctx.hcatMarkovBruteForce.assert_not_called() diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py index 7d6f0eb..8f30e16 100644 --- a/tests/test_combipow_attack.py +++ b/tests/test_combipow_attack.py @@ -66,7 +66,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("correct\nhorse\nbattery\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() call_args = ctx.hcatCombipow.call_args @@ -75,12 +76,23 @@ class TestCombipowCrack: ) assert use_space is True + def test_wordlist_path_uses_autocomplete_not_input(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("correct\nhorse\n") + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): + attacks.combipow_crack(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + def test_calls_hcatCombipow_without_space_sep(self, tmp_path): attacks = _load_attacks() ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("correct\nhorse\nbattery\n") - with patch("builtins.input", side_effect=[str(wl), "n"]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=["n"]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() call_args = ctx.hcatCombipow.call_args @@ -94,7 +106,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(64)) + "\n") - with patch("builtins.input", return_value=str(wl)): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_not_called() @@ -103,7 +116,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(63)) + "\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() @@ -112,7 +126,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("test\n") - with patch("builtins.input", side_effect=["/nonexistent.txt", str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = ["/nonexistent.txt", str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() @@ -121,7 +136,8 @@ class TestCombipowCrack: ctx = _make_ctx(hash_type="3200", hash_file="/tmp/bcrypt.txt") wl = tmp_path / "words.txt" wl.write_text("word1\nword2\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() args = ctx.hcatCombipow.call_args[0] @@ -134,7 +150,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(31)) + "\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) captured = capsys.readouterr() assert "large" in captured.out.lower() or "warning" in captured.out.lower() diff --git a/tests/test_custom_path_autocomplete.py b/tests/test_custom_path_autocomplete.py new file mode 100644 index 0000000..271aad3 --- /dev/null +++ b/tests/test_custom_path_autocomplete.py @@ -0,0 +1,80 @@ +"""Regression tests: custom-path prompts must offer tab autocomplete. + +Previously the "p. Enter a custom path" branches (OMEN, Markov) and the +combipow wordlist prompt used a bare ``input()`` with no readline completer, +so TAB did nothing. They now route through ``select_file_with_autocomplete``. +Also covers the canonical selector dropping its completer afterward so later +plain prompts don't inherit stale file-path completion. +""" + +import os +import readline +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load_attacks(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.attacks as attacks # noqa: PLC0415 + + return attacks + + +def _make_ctx(): + ctx = MagicMock() + ctx.hcatHashType = "1000" + ctx.hcatHashFile = "/tmp/hashes.txt" + ctx.hcatWordlists = "/tmp/wordlists" + ctx.list_wordlist_files.return_value = [] + return ctx + + +class TestOmenCustomPath: + def test_custom_path_uses_autocomplete(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "/data/custom.txt" + with patch("builtins.input", side_effect=["p"]): + result = attacks._pick_training_wordlist(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + assert result == "/data/custom.txt" + + def test_custom_path_blank_returns_none(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = None + with patch("builtins.input", side_effect=["p"]): + result = attacks._pick_training_wordlist(ctx) + assert result is None + + +class TestMarkovCustomPath: + def test_custom_path_uses_autocomplete(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "/data/train.txt" + # out file absent -> "0" option not offered + with patch("builtins.input", side_effect=["p"]): + result = attacks._markov_pick_training_source(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + assert result == "/data/train.txt" + + +class TestSelectorResetsCompleter: + def test_completer_reset_after_selection(self): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.main as m # noqa: PLC0415 + + sentinel = object() + readline.set_completer(lambda t, s: None) + with patch("builtins.input", return_value="/some/path"): + m.select_file_with_autocomplete("Pick a file") + assert readline.get_completer() is None + del sentinel diff --git a/tests/test_hcat_ollama.py b/tests/test_hcat_ollama.py new file mode 100644 index 0000000..95b0a5a --- /dev/null +++ b/tests/test_hcat_ollama.py @@ -0,0 +1,316 @@ +"""Orchestration tests for hcatOllama (candidate generation is mocked).""" + +import os +from types import SimpleNamespace +from contextlib import contextmanager +from unittest import mock + +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import main as hc_main # noqa: E402 + +OLLAMA_URL = "http://localhost:11434" +MODEL = "qwen2.5:32b" + + +@pytest.fixture +def ollama_env(tmp_path): + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + wordlist = tmp_path / "sample.txt" + wordlist.write_text("password\n123456\nletmein\n") + return SimpleNamespace( + tmp_path=tmp_path, hash_file=str(hash_file), wordlist=str(wordlist) + ) + + +@contextmanager +def ollama_globals(tmp_path, tuning="", potfile=""): + rules_dir = str(tmp_path / "rules") + os.makedirs(rules_dir, exist_ok=True) + with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \ + mock.patch.object(hc_main, "ollamaModel", MODEL), \ + mock.patch.object(hc_main, "ollamaNumCtx", 2048), \ + mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ + mock.patch.object(hc_main, "hcatTuning", tuning), \ + mock.patch.object(hc_main, "hcatPotfilePath", potfile), \ + mock.patch.object(hc_main, "rulesDirectory", rules_dir), \ + mock.patch("hate_crack.main.generate_session_id", return_value="s"): + yield + + +def _make_proc(wait_return=0): + proc = mock.MagicMock() + proc.wait.return_value = wait_return + proc.communicate.return_value = (b"", b"") + proc.returncode = wait_return + return proc + + +def test_pull_ollama_model_is_gone(): + assert not hasattr(hc_main, "_pull_ollama_model") + + +def test_target_mode_passes_dict_through(ollama_env): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama( + "0", ollama_env.hash_file, "target", + {"company": "ACME", "industry": "tech", "location": "NYC"}, + ) + gen.assert_called_once() + args = gen.call_args[0] + assert args[0] == OLLAMA_URL and args[1] == MODEL and args[3] == "target" + assert args[4] == {"company": "ACME", "industry": "tech", "location": "NYC"} + + +def test_wordlist_mode_reads_file_and_passes_sample(ollama_env): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", ollama_env.wordlist) + ctx_data = gen.call_args[0][4] + assert "letmein" in ctx_data["sample"] + + +def test_wordlist_mode_strips_hash_prefix(ollama_env): + # hash:password lines should contribute only the post-colon plaintext. + dump = ollama_env.tmp_path / "dump.txt" + dump.write_text("aad3b435:Winter2024\nnocolonline\n") + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["x"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", str(dump)) + sample = gen.call_args[0][4]["sample"] + assert "Winter2024" in sample + assert "aad3b435" not in sample + assert "nocolonline" in sample + + +def test_per_rule_runs_hashcat_with_rule_flag(ollama_env): + rules_dir = str(ollama_env.tmp_path / "rules") + os.makedirs(rules_dir, exist_ok=True) + rule_file = os.path.join(rules_dir, "test.rule") + open(rule_file, "w").close() + calls = [] + + def track_popen(cmd, **kwargs): + calls.append(list(cmd)) + return _make_proc() + + with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \ + mock.patch.object(hc_main, "ollamaModel", MODEL), \ + mock.patch.object(hc_main, "ollamaNumCtx", 2048), \ + mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ + mock.patch.object(hc_main, "hcatTuning", ""), \ + mock.patch.object(hc_main, "hcatPotfilePath", ""), \ + mock.patch.object(hc_main, "rulesDirectory", rules_dir), \ + mock.patch("hate_crack.main.generate_session_id", return_value="s"), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen): + hc_main.hcatOllama("1000", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + + # call 0 = plain wordlist run, call 1 = rule run + assert len(calls) == 2 + rule_call = calls[1] + assert "-r" in rule_call + assert rule_file in rule_call + + +def test_missing_wordlist_prints_error(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates") as gen: + hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", "/no/such.txt") + captured = capsys.readouterr() + assert "Wordlist not found" in captured.out + gen.assert_not_called() + + +def test_writes_candidates_and_runs_hashcat(ollama_env): + calls = [] + + def track_popen(cmd, **kwargs): + calls.append(list(cmd)) + return _make_proc() + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1", "Summer2024"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen): + hc_main.hcatOllama("1000", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + + candidates_path = f"{ollama_env.hash_file}.ollama_candidates" + assert os.path.isfile(candidates_path) + with open(candidates_path) as f: + assert f.read().splitlines() == ["Password1", "Summer2024"] + # First hashcat call is the plain wordlist run with the candidates file. + assert calls and candidates_path in calls[0] + assert "-r" not in calls[0] + + +def test_empty_candidates_skips_hashcat(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", return_value=[]), \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + captured = capsys.readouterr() + assert "no usable" in captured.out.lower() + popen.assert_not_called() + + +def test_generation_error_reports_and_aborts(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + side_effect=Exception("connection refused")), \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + captured = capsys.readouterr() + assert "Ensure Ollama is running" in captured.out + popen.assert_not_called() + + +def test_timeout_error_reports_timeout_guidance(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch.object(hc_main, "ollamaTimeout", 300.0), \ + mock.patch("hate_crack.main.llm.generate_candidates", + side_effect=hc_main.llm.LLMTimeoutError("timed out")), \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + captured = capsys.readouterr() + assert "timed out" in captured.out.lower() + assert "300" in captured.out + assert "ollamaTimeout" in captured.out + assert "Ensure Ollama is running" not in captured.out + popen.assert_not_called() + assert not os.path.isfile(f"{ollama_env.hash_file}.ollama_candidates") + + +def test_value_error_reports_message_and_aborts(ollama_env, capsys): + """Covers the defensive `except ValueError` handler in hcatOllama.""" + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + side_effect=ValueError("boom")), \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + captured = capsys.readouterr() + assert "Error: boom" in captured.out + popen.assert_not_called() + assert not os.path.isfile(f"{ollama_env.hash_file}.ollama_candidates") + + +def test_timeout_config_forwarded_to_generate_candidates(ollama_env): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch.object(hc_main, "ollamaTimeout", 77.0), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + assert gen.call_args.kwargs["timeout"] == 77.0 + + +def test_unknown_mode_prints_error(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates") as gen: + hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", {}) + captured = capsys.readouterr() + assert "Unknown LLM generation mode" in captured.out + gen.assert_not_called() + + +# --------------------------------------------------------------------------- +# cracked mode +# --------------------------------------------------------------------------- + + +def test_cracked_mode_samples_out_file(ollama_env): + """cracked mode reads .out and passes the plaintexts as the sample.""" + with open(f"{ollama_env.hash_file}.out", "w") as f: + f.write("aad3b435:Summer2024!\nbbccddee:Acme2023\n") + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Winter2025!"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", None) + + args = gen.call_args[0] + assert args[3] == "cracked" + sample = args[4]["sample"].splitlines() + assert sample == ["Summer2024!", "Acme2023"] + # Hash portions must not leak into the prompt. + assert "aad3b435" not in args[4]["sample"] + + +def test_cracked_mode_accepts_explicit_path(ollama_env): + """An explicit path in context_data is honoured (what attacks.py passes).""" + out_path = f"{ollama_env.hash_file}.out" + with open(out_path, "w") as f: + f.write("hash:Falcons2024\n") + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Falcons2025"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", out_path) + + assert "Falcons2024" in gen.call_args[0][4]["sample"] + + +def test_cracked_mode_missing_out_file_prints_error(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates") as gen, \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "cracked", None) + captured = capsys.readouterr() + assert "No cracked passwords found" in captured.out + gen.assert_not_called() + popen.assert_not_called() + + +def test_cracked_mode_empty_out_file_prints_error(ollama_env, capsys): + """An existing but empty/blank .out must abort before calling the LLM.""" + with open(f"{ollama_env.hash_file}.out", "w") as f: + f.write("\n \n") + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates") as gen, \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "cracked", None) + captured = capsys.readouterr() + assert "No cracked passwords yet" in captured.out + gen.assert_not_called() + popen.assert_not_called() + + +def test_cracked_mode_writes_candidates_to_separate_file(ollama_env): + """The candidate file must be distinct from the .out file it samples.""" + out_path = f"{ollama_env.hash_file}.out" + with open(out_path, "w") as f: + f.write("hash:Summer2024!\n") + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Winter2025!"]), \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", None) + + candidates_path = f"{ollama_env.hash_file}.ollama_candidates" + assert candidates_path != out_path + with open(candidates_path) as f: + assert f.read().splitlines() == ["Winter2025!"] + # The sampled source file is untouched by candidate writing. + with open(out_path) as f: + assert f.read() == "hash:Summer2024!\n" diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..1e8ef94 --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,322 @@ +"""Unit tests for hate_crack.llm.generate_candidates.""" + +import os +from unittest import mock + +import httpx +import instructor +import openai +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import llm # noqa: E402 + + +def _patch_agent(candidates): + """Patch the client builders + AtomicAgent so no network happens. + + Returns the AtomicAgent class mock so callers can inspect construction. + """ + result = mock.MagicMock() + result.candidates = list(candidates) + + agent_instance = mock.MagicMock() + agent_instance.run.return_value = result + + agent_cls = mock.MagicMock() + # AtomicAgent[In, Out](config=...) -> agent_instance + agent_cls.__getitem__.return_value.return_value = agent_instance + + return ( + mock.patch("hate_crack.llm.instructor.from_openai", return_value=mock.MagicMock(spec=instructor.Instructor)), + mock.patch("hate_crack.llm.OpenAI"), + mock.patch("hate_crack.llm.AtomicAgent", agent_cls), + agent_cls, + agent_instance, + ) + + +def test_target_mode_returns_candidates(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent( + ["AcmeCorp2024", "Finance123"] + ) + with p_instr, p_openai, p_agent: + out = llm.generate_candidates( + "http://localhost:11434", + "qwen2.5:32b", + 2048, + "target", + {"company": "AcmeCorp", "industry": "Finance", "location": "NYC"}, + ) + assert out == ["AcmeCorp2024", "Finance123"] + # The instruction the agent received must include the target context. + run_arg = agent_instance.run.call_args[0][0] + assert "AcmeCorp" in run_arg.request + assert "Finance" in run_arg.request + assert "NYC" in run_arg.request + + +def test_wordlist_mode_includes_sample_in_request(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["Passw0rd"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", + "qwen2.5:32b", + 2048, + "wordlist", + {"sample": "password\nletmein\nsummer2024"}, + ) + run_arg = agent_instance.run.call_args[0][0] + assert "letmein" in run_arg.request + + +def test_cracked_mode_includes_sample_in_request(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["Winter2025!"]) + with p_instr, p_openai, p_agent: + out = llm.generate_candidates( + "http://localhost:11434", + "qwen2.5:32b", + 2048, + "cracked", + {"sample": "Summer2024!\nAcme2023\nP@ssw0rd1"}, + ) + assert out == ["Winter2025!"] + run_arg = agent_instance.run.call_args[0][0] + assert "Acme2023" in run_arg.request + # The request must tell the model not to regenerate what is already cracked. + assert "NEW" in run_arg.request + assert "Do not repeat" in run_arg.request + + +def test_cracked_mode_uses_its_own_prompt_not_the_denylist_one(): + """cracked mode must select _CRACKED_PROMPT, never the denylist _WORDLIST_PROMPT.""" + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "cracked", {"sample": "Summer2024!"}, + ) + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.system_prompt_generator is llm._CRACKED_PROMPT + assert config.system_prompt_generator is not llm._WORDLIST_PROMPT + + +def test_wordlist_mode_still_uses_wordlist_prompt(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "wordlist", {"sample": "password"}, + ) + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.system_prompt_generator is llm._WORDLIST_PROMPT + + +def test_target_mode_still_uses_target_prompt(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + ) + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.system_prompt_generator is llm._TARGET_PROMPT + + +def test_cracked_prompt_is_offensive_not_denylist(): + """_CRACKED_PROMPT's objective is candidate generation, not denylist building.""" + rendered = llm._CRACKED_PROMPT.generate_prompt() + assert "denylist" not in rendered.lower() + assert "authorized penetration test" in rendered.lower() + assert "already recovered" in rendered.lower() + + +def test_prompts_map_covers_every_supported_mode(): + assert set(llm._PROMPTS) == {"target", "wordlist", "cracked"} + + +def test_dedupes_and_caps_length(): + long_pw = "A" * 129 + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent( + [" keep ", "keep", "dup", "dup", long_pw, ""] + ) + with p_instr, p_openai, p_agent: + out = llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + ) + assert out == ["keep", "dup"] # trimmed, deduped, blank + >128 dropped + + +def test_num_ctx_forwarded_via_model_api_parameters(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 4096, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + ) + # AtomicAgent[In, Out](config=) — inspect the config. + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.model == "qwen2.5:32b" + assert config.model_api_parameters["extra_body"]["options"]["num_ctx"] == 4096 + + +def test_build_request_rejects_unknown_mode(): + """Unit test of _build_request's mode validation only — no agent involved.""" + with pytest.raises(ValueError, match="Unknown LLM generation mode: bogus"): + llm._build_request("bogus", {}) + + +def test_generate_candidates_rejects_unknown_mode_before_building_client(): + """generate_candidates surfaces _build_request's ValueError to its caller.""" + with pytest.raises(ValueError, match="Unknown LLM generation mode: bogus"): + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, "bogus", {}, + ) + + +def test_timeout_forwarded_to_openai_client(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai as openai_cls, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + timeout=42.5, + ) + assert openai_cls.call_args.kwargs["timeout"] == 42.5 + + +def test_default_timeout_used_when_omitted(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai as openai_cls, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + ) + assert llm.DEFAULT_TIMEOUT_SECONDS == 300.0 + assert openai_cls.call_args.kwargs["timeout"] == llm.DEFAULT_TIMEOUT_SECONDS + + +def test_api_timeout_reraised_as_domain_error(): + """openai.APITimeoutError is translated into llm.LLMTimeoutError.""" + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + agent_instance.run.side_effect = openai.APITimeoutError( + request=httpx.Request("POST", "http://localhost:11434/v1/chat/completions") + ) + with p_instr, p_openai, p_agent: + with pytest.raises(llm.LLMTimeoutError): + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + timeout=1.0, + ) + + +# --------------------------------------------------------------------------- +# research_target +# --------------------------------------------------------------------------- + + +def _patch_research_agent(industry, location): + """Patch client builders + AtomicAgent for a research call. No network.""" + result = mock.MagicMock() + result.industry = industry + result.location = location + + agent_instance = mock.MagicMock() + agent_instance.run.return_value = result + + agent_cls = mock.MagicMock() + agent_cls.__getitem__.return_value.return_value = agent_instance + + return ( + mock.patch( + "hate_crack.llm.instructor.from_openai", + return_value=mock.MagicMock(spec=instructor.Instructor), + ), + mock.patch("hate_crack.llm.OpenAI"), + mock.patch("hate_crack.llm.AtomicAgent", agent_cls), + agent_cls, + agent_instance, + ) + + +def test_research_target_returns_fields_and_passes_company(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + "freight rail maintenance", "Omaha, Nebraska" + ) + with p_instr, p_openai, p_agent: + out = llm.research_target( + "http://localhost:11434", "qwen2.5:32b", 2048, "Acme Rail Services" + ) + assert out.industry == "freight rail maintenance" + assert out.location == "Omaha, Nebraska" + assert agent_instance.run.call_args[0][0].company == "Acme Rail Services" + + +def test_research_target_uses_research_prompt_and_num_ctx(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + "x", "y" + ) + with p_instr, p_openai, p_agent: + llm.research_target("http://localhost:11434", "qwen2.5:32b", 4096, "Acme") + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.system_prompt_generator is llm._RESEARCH_PROMPT + assert config.model_api_parameters["extra_body"]["options"]["num_ctx"] == 4096 + + +def test_research_prompt_tells_model_to_return_empty_when_unsure(): + rendered = llm._RESEARCH_PROMPT.generate_prompt().lower() + assert "empty string" in rendered + assert "no internet access" in rendered + + +def test_research_target_strips_and_blanks_whitespace_only(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + " healthcare ", " " + ) + with p_instr, p_openai, p_agent: + out = llm.research_target("http://localhost:11434", "m", 2048, "Acme") + assert out.industry == "healthcare" + assert out.location == "" + + +def test_research_target_caps_overlong_values(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + "A" * 500, "B" * (llm.MAX_RESEARCH_FIELD_LEN + 1) + ) + with p_instr, p_openai, p_agent: + out = llm.research_target("http://localhost:11434", "m", 2048, "Acme") + assert len(out.industry) == llm.MAX_RESEARCH_FIELD_LEN + assert len(out.location) == llm.MAX_RESEARCH_FIELD_LEN + + +def test_research_target_tolerates_non_string_fields(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + None, 42 + ) + with p_instr, p_openai, p_agent: + out = llm.research_target("http://localhost:11434", "m", 2048, "Acme") + assert out.industry == "" + assert out.location == "" + + +def test_research_target_timeout_forwarded_and_translated(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + "x", "y" + ) + agent_instance.run.side_effect = openai.APITimeoutError( + request=httpx.Request("POST", "http://localhost:11434/v1/chat/completions") + ) + with p_instr, p_openai as openai_cls, p_agent: + with pytest.raises(llm.LLMTimeoutError): + llm.research_target( + "http://localhost:11434", "m", 2048, "Acme", timeout=7.5 + ) + assert openai_cls.call_args.kwargs["timeout"] == 7.5 + + +def test_clean_research_field_collapses_internal_whitespace(): + assert llm.clean_research_field("commercial \n construction") == ( + "commercial construction" + ) diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py index 15eb7a3..c71112e 100644 --- a/tests/test_main_pcfg.py +++ b/tests/test_main_pcfg.py @@ -17,6 +17,15 @@ class TestHcatPCFG: hash_file = str(tmp_path / "hashes.txt") Path(hash_file).write_text("dummy") + # hcatPCFG resolves pcfg_guesser.py under the module global hate_path + # and bails early if it's missing. Pin hate_path to a tmp dir with a + # stub script so the test is hermetic — independent of the real + # pcfg_cracker submodule being checked out and of any hate_path value + # leaked by an earlier test (hate_crack.main is shared session-wide). + pcfg_dir = tmp_path / "pcfg_cracker" + pcfg_dir.mkdir() + (pcfg_dir / "pcfg_guesser.py").write_text("# stub") + captured_calls = [] class FakeProc: @@ -27,6 +36,7 @@ class TestHcatPCFG: with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \ patch("hate_crack.main._run_hcat_cmd") as mock_run, \ + patch.object(main_module, "hate_path", str(tmp_path)), \ patch.object(main_module, "hcatBin", "hashcat"), \ patch.object(main_module, "hcatTuning", ""), \ patch.object(main_module, "hcatPotfilePath", ""), \ diff --git a/tests/test_markov_e2e.py b/tests/test_markov_e2e.py index a302876..853ebda 100644 --- a/tests/test_markov_e2e.py +++ b/tests/test_markov_e2e.py @@ -10,14 +10,18 @@ import pytest class TestMarkovE2E: """End-to-end tests for complete markov attack workflow.""" - def test_markov_training_plain_text(self, tmp_path: Path) -> None: + def test_markov_training_plain_text(self, tmp_path: Path, monkeypatch) -> None: """Test markov training with plain text wordlist.""" from hate_crack import main - # Setup paths - main.hate_path = Path(__file__).resolve().parents[1] - main.hcatHcstat2genBin = "hcstat2gen.bin" - bin_path = main.hate_path / "hashcat-utils" / "bin" / "hcstat2gen.bin" + # Setup paths. Use monkeypatch so these module globals are restored + # after the test — hate_crack.main is imported once and shared across + # the whole session, so a raw assignment here would leak into every + # later test (e.g. test_main_pcfg, which reads the ambient hate_path). + repo_root = Path(__file__).resolve().parents[1] + monkeypatch.setattr(main, "hate_path", repo_root) + monkeypatch.setattr(main, "hcatHcstat2genBin", "hcstat2gen.bin") + bin_path = repo_root / "hashcat-utils" / "bin" / "hcstat2gen.bin" if not bin_path.is_file(): pytest.skip(f"hcstat2gen.bin not compiled: {bin_path}") @@ -39,14 +43,16 @@ class TestMarkovE2E: assert hcstat2_path.exists(), ".hcstat2 file should be created" assert hcstat2_path.stat().st_size > 0, ".hcstat2 file should not be empty" - def test_markov_training_gzipped(self, tmp_path: Path) -> None: + def test_markov_training_gzipped(self, tmp_path: Path, monkeypatch) -> None: """Test markov training with gzipped wordlist.""" from hate_crack import main - # Setup paths - main.hate_path = Path(__file__).resolve().parents[1] - main.hcatHcstat2genBin = "hcstat2gen.bin" - bin_path = main.hate_path / "hashcat-utils" / "bin" / "hcstat2gen.bin" + # Setup paths (monkeypatch so these session-shared module globals are + # restored after the test — see test_markov_training_plain_text). + repo_root = Path(__file__).resolve().parents[1] + monkeypatch.setattr(main, "hate_path", repo_root) + monkeypatch.setattr(main, "hcatHcstat2genBin", "hcstat2gen.bin") + bin_path = repo_root / "hashcat-utils" / "bin" / "hcstat2gen.bin" if not bin_path.is_file(): pytest.skip(f"hcstat2gen.bin not compiled: {bin_path}") diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py new file mode 100644 index 0000000..07aba49 --- /dev/null +++ b/tests/test_noninteractive.py @@ -0,0 +1,259 @@ +import os +import sys +from types import SimpleNamespace + +import pytest + +import hate_crack.main as hc_main +from hate_crack import noninteractive as ni + + +def _ctx_with_rules(tmp_path, *rule_names): + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + for name in rule_names: + (rules_dir / name).write_text(":\n") + return SimpleNamespace(rulesDirectory=str(rules_dir)) + + +def test_build_rule_chains_no_rules_returns_empty_chain(tmp_path): + ctx = _ctx_with_rules(tmp_path) + assert ni.build_rule_chains(ctx, []) == [""] + assert ni.build_rule_chains(ctx, None) == [""] + + +def test_build_rule_chains_single_rule(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule"]) + expected = os.path.join(ctx.rulesDirectory, "best64.rule") + assert chains == [f"-r {expected}"] + + +def test_build_rule_chains_chained_token(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule+d3ad0ne.rule"]) + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a} -r {b}"] + + +def test_build_rule_chains_multiple_tokens_are_separate_passes(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule", "d3ad0ne.rule"]) + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a}", f"-r {b}"] + + +def test_build_rule_chains_missing_file_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path) + with pytest.raises(FileNotFoundError, match="nope.rule"): + ni.build_rule_chains(ctx, ["nope.rule"]) + + +def test_build_rule_chains_empty_token_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + with pytest.raises(ValueError): + ni.build_rule_chains(ctx, ["+"]) + + +def _spy_ctx(tmp_path, **overrides): + calls = [] + + def rec(name): + def _fn(*a, **k): + calls.append((name, a, k)) + return _fn + + ctx = SimpleNamespace( + calls=calls, + hcatHashType="1000", + hcatHashFile=str(tmp_path / "hashes.txt"), + rulesDirectory=str(tmp_path / "rules"), + resolve_path=lambda p: os.path.abspath(os.path.expanduser(p)) if p else None, + hcatQuickDictionary=rec("hcatQuickDictionary"), + hcatDictionary=rec("hcatDictionary"), + hcatBruteForce=rec("hcatBruteForce"), + hcatTopMask=rec("hcatTopMask"), + ) + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + +def test_dispatch_quick_calls_quick_dictionary(tmp_path): + (tmp_path / "rules").mkdir() + (tmp_path / "rules" / "best64.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["best64.rule"]) + code = ni.run_noninteractive(ctx, args) + assert code == 0 + assert [c[0] for c in ctx.calls] == ["hcatQuickDictionary"] + name, a, k = ctx.calls[0] + assert a[0] == "1000" + assert a[1] == ctx.hcatHashFile + assert a[2] == f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}" + assert a[3] == os.path.abspath(str(wl)) + + +def test_dispatch_quick_missing_wordlist_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rule_files=[]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_quick_unknown_rule_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["ghost.rule"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_dict_calls_dictionary(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="dict") + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][0] == "hcatDictionary" + assert ctx.calls[0][1] == ("1000", ctx.hcatHashFile) + + +def test_dispatch_brute_calls_bruteforce_with_lengths(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="brute", min_len=2, max_len=6) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatBruteForce", ("1000", ctx.hcatHashFile, 2, 6), {}) + + +def test_dispatch_topmask_converts_hours_to_seconds(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="topmask", target_time=4) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatTopMask", ("1000", ctx.hcatHashFile, 4 * 3600), {}) + + +def test_dispatch_unknown_command_returns_2(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="bogus") + assert ni.run_noninteractive(ctx, args) == 2 + assert ctx.calls == [] + + +def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): + rules = tmp_path / "rules" + rules.mkdir() + (rules / "best64.rule").write_text(":\n") + (rules / "d3ad0ne.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace( + command="quick", wordlist=str(wl), rule_files=["best64.rule", "d3ad0ne.rule"] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert len(ctx.calls) == 2 + chains = [c[1][2] for c in ctx.calls] + assert chains == [ + f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}", + f"-r {os.path.join(ctx.rulesDirectory, 'd3ad0ne.rule')}", + ] + + +def _run_main(monkeypatch, argv): + monkeypatch.setattr(sys, "argv", ["hate_crack.py"] + argv) + # main()'s pre-dispatch potfile-recovery step shells out to the real hashcat + # binary, which is absent in CI. Stub it — these tests exercise dispatch + # routing and prompt suppression, not potfile recovery. + monkeypatch.setattr(hc_main, "_run_hashcat_show", lambda *a, **k: None) + with pytest.raises(SystemExit) as excinfo: + hc_main.main() + return excinfo.value.code + + +def _make_ntlm_hashfile(tmp_path): + # Bare 32-hex NTLM hash: exercises the non-pwdump preprocessing branch. + hf = tmp_path / "hashes.txt" + hf.write_text("aad3b435b51404eeaad3b435b51404ee\n") + return hf + + +def test_main_quick_dispatches_without_prompting(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append((a, k)) + ) + monkeypatch.setattr( + "builtins.input", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompted")), + ) + code = _run_main(monkeypatch, ["quick", str(hf), "1000", "--wordlist", str(wl)]) + assert code == 0 + assert len(calls) == 1 + + +def test_main_brute_dispatches(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + calls = [] + monkeypatch.setattr(hc_main, "hcatBruteForce", lambda *a, **k: calls.append(a)) + code = _run_main( + monkeypatch, ["brute", str(hf), "1000", "--min", "1", "--max", "8"] + ) + assert code == 0 + assert calls[0][2] == 1 and calls[0][3] == 8 + + +def test_main_quick_bad_hashtype_errors(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "w.txt" + wl.write_text("x\n") + code = _run_main( + monkeypatch, ["quick", str(hf), "notanumber", "--wordlist", str(wl)] + ) + assert code != 0 + + +def test_main_quick_with_rules_dispatches(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best64.rule").write_text(":\n") + monkeypatch.setattr(hc_main, "rulesDirectory", str(rules_dir)) + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a) + ) + code = _run_main( + monkeypatch, + ["quick", str(hf), "1000", "--wordlist", str(wl), "--rules", "best64.rule"], + ) + assert code == 0 + assert len(calls) == 1 + # the rule chain (4th positional arg) must reference best64.rule + assert "best64.rule" in calls[0][2] + + +def test_main_debug_flag_before_subcommand(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "w.txt" + wl.write_text("x\n") + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a) + ) + code = _run_main( + monkeypatch, + ["--debug", "quick", str(hf), "1000", "--wordlist", str(wl)], + ) + assert code == 0 + assert len(calls) == 1 diff --git a/tests/test_ollama_target_research.py b/tests/test_ollama_target_research.py new file mode 100644 index 0000000..222240a --- /dev/null +++ b/tests/test_ollama_target_research.py @@ -0,0 +1,301 @@ +"""Tests for LLM target-research pre-fill (menu 12, Target info mode). + +Covers both layers: hcatOllamaResearchTarget in main (spinner + failure +degradation) and ollama_attack's editable-default prompts in attacks. The LLM is +always mocked; no network. +""" + +import os +from unittest import mock +from unittest.mock import MagicMock, patch + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import llm # noqa: E402 +from hate_crack import main as hc_main # noqa: E402 +from hate_crack.attacks import ollama_attack # noqa: E402 + + +def _make_ctx(hash_type: str = "1000", hash_file: str = "/tmp/hashes.txt") -> MagicMock: + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +class _InputRecorder: + """input() stub that records prompts and replays canned answers.""" + + def __init__(self, answers): + self.answers = list(answers) + self.prompts: list[str] = [] + + def __call__(self, prompt=""): + self.prompts.append(prompt) + return self.answers.pop(0) + + +def _run_target_mode(ctx, answers): + recorder = _InputRecorder(answers) + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", recorder), + ): + ollama_attack(ctx) + return recorder + + +# --------------------------------------------------------------------------- +# attacks layer: prompt defaults +# --------------------------------------------------------------------------- + + +class TestResearchPrefill: + def test_successful_research_prefills_defaults(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "freight rail maintenance", + "location": "Omaha, Nebraska", + } + + recorder = _run_target_mode(ctx, ["Acme Rail", "", ""]) + + ctx.hcatOllamaResearchTarget.assert_called_once_with("Acme Rail") + assert recorder.prompts == [ + "Company name: ", + "Industry (freight rail maintenance): ", + "Location (Omaha, Nebraska): ", + ] + + def test_enter_accepts_the_suggested_defaults(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "healthcare", + "location": "Austin, Texas", + } + + _run_target_mode(ctx, ["Acme", "", ""]) + + assert ctx.hcatOllama.call_args[0][3] == { + "company": "Acme", + "industry": "healthcare", + "location": "Austin, Texas", + } + + def test_typed_input_overrides_the_default(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "healthcare", + "location": "Austin, Texas", + } + + _run_target_mode(ctx, ["Acme", " banking ", "Berlin"]) + + assert ctx.hcatOllama.call_args[0][3] == { + "company": "Acme", + "industry": "banking", + "location": "Berlin", + } + + def test_partial_research_prefills_only_known_field(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "mining", + "location": "", + } + + recorder = _run_target_mode(ctx, ["Acme", "", "Perth"]) + + assert recorder.prompts[1] == "Industry (mining): " + assert recorder.prompts[2] == "Location: " + assert ctx.hcatOllama.call_args[0][3]["location"] == "Perth" + + def test_empty_research_falls_back_to_blank_prompts(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = {"industry": "", "location": ""} + + recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"]) + + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + assert ctx.hcatOllama.call_args[0][3] == { + "company": "Acme", + "industry": "tech", + "location": "NYC", + } + + def test_whitespace_only_research_is_treated_as_no_suggestion(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": " ", + "location": "\t\n", + } + + recorder = _run_target_mode(ctx, ["Acme", "", ""]) + + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + + def test_overlong_suggestion_is_capped_in_the_prompt(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "z" * 500, + "location": "", + } + + recorder = _run_target_mode(ctx, ["Acme", "", ""]) + + assert recorder.prompts[1] == f"Industry ({'z' * llm.MAX_RESEARCH_FIELD_LEN}): " + industry = ctx.hcatOllama.call_args[0][3]["industry"] + assert len(industry) == llm.MAX_RESEARCH_FIELD_LEN + + def test_research_exception_falls_back_and_does_not_abort(self) -> None: + """Even an unexpected raise from the research call must not block.""" + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.side_effect = RuntimeError("boom") + + recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"]) + + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + ctx.hcatOllama.assert_called_once() + + def test_non_dict_research_result_falls_back(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = None + + recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"]) + + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + + def test_blank_company_skips_research_entirely(self) -> None: + ctx = _make_ctx() + + recorder = _run_target_mode(ctx, ["", "tech", "NYC"]) + + ctx.hcatOllamaResearchTarget.assert_not_called() + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + + def test_suggestions_are_labelled_as_guesses(self, capsys) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "healthcare", + "location": "Austin, Texas", + } + + _run_target_mode(ctx, ["Acme", "", ""]) + + out = capsys.readouterr().out + assert "GUESSES" in out + assert "not verified OSINT" in out + + def test_no_guess_banner_when_research_returns_nothing(self, capsys) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = {"industry": "", "location": ""} + + _run_target_mode(ctx, ["Acme", "", ""]) + + assert "GUESSES" not in capsys.readouterr().out + + def test_hcatOllama_call_shape_unchanged(self) -> None: + ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt") + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "healthcare", + "location": "Austin", + } + + _run_target_mode(ctx, ["Acme", "", ""]) + + args = ctx.hcatOllama.call_args[0] + assert args[0] == "1800" + assert args[1] == "/tmp/sha512.txt" + assert args[2] == "target" + assert set(args[3]) == {"company", "industry", "location"} + + +# --------------------------------------------------------------------------- +# main layer: hcatOllamaResearchTarget +# --------------------------------------------------------------------------- + + +class TestHcatOllamaResearchTarget: + def test_returns_researched_fields_and_shows_progress(self) -> None: + result = hc_main.llm.TargetResearchOutput(industry="mining", location="Perth") + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object(hc_main, "ollamaModel", "qwen2.5:32b"), + mock.patch.object(hc_main, "ollamaTimeout", 30.0), + mock.patch.object(hc_main.llm, "research_target", return_value=result), + mock.patch.object(hc_main, "spinner") as spin, + ): + out = hc_main.hcatOllamaResearchTarget("Acme") + + assert out == {"industry": "mining", "location": "Perth"} + # The call must be wrapped in the progress spinner, not look like a hang. + assert spin.call_count == 1 + assert "Acme" in spin.call_args[0][0] + + def test_forwards_config_values_to_research_target(self) -> None: + result = hc_main.llm.TargetResearchOutput(industry="", location="") + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object(hc_main, "ollamaUrl", "http://localhost:11434"), + mock.patch.object(hc_main, "ollamaModel", "m"), + mock.patch.object(hc_main, "ollamaNumCtx", 4096), + mock.patch.object(hc_main, "ollamaTimeout", 12.5), + mock.patch.object( + hc_main.llm, "research_target", return_value=result + ) as research, + ): + hc_main.hcatOllamaResearchTarget("Acme") + + assert research.call_args[0] == ("http://localhost:11434", "m", 4096, "Acme") + assert research.call_args.kwargs["timeout"] == 12.5 + + def test_timeout_degrades_to_blank_suggestions(self, capsys) -> None: + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object(hc_main, "ollamaTimeout", 5.0), + mock.patch.object( + hc_main.llm, + "research_target", + side_effect=hc_main.llm.LLMTimeoutError("no response"), + ), + ): + out = hc_main.hcatOllamaResearchTarget("Acme") + + assert out == {"industry": "", "location": ""} + assert "timed out" in capsys.readouterr().out + + def test_connection_failure_degrades_to_blank_suggestions(self, capsys) -> None: + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object( + hc_main.llm, + "research_target", + side_effect=ConnectionError("connection refused"), + ), + ): + out = hc_main.hcatOllamaResearchTarget("Acme") + + assert out == {"industry": "", "location": ""} + assert "unavailable" in capsys.readouterr().out + + def test_disabled_by_config_skips_the_model_call(self) -> None: + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", False), + mock.patch.object(hc_main.llm, "research_target") as research, + ): + out = hc_main.hcatOllamaResearchTarget("Acme") + + research.assert_not_called() + assert out == {"industry": "", "location": ""} + + def test_blank_company_skips_the_model_call(self) -> None: + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object(hc_main.llm, "research_target") as research, + ): + out = hc_main.hcatOllamaResearchTarget("") + + research.assert_not_called() + assert out == {"industry": "", "location": ""} + + def test_auto_research_default_is_enabled(self) -> None: + assert hc_main.ollamaAutoResearch is True diff --git a/tests/test_ollama_url_normalization.py b/tests/test_ollama_url_normalization.py new file mode 100644 index 0000000..845f116 --- /dev/null +++ b/tests/test_ollama_url_normalization.py @@ -0,0 +1,75 @@ +"""Tests for OLLAMA_HOST -> ollamaUrl normalization (issue #119). + +``ollamaUrl`` used to be built as ``"http://" + OLLAMA_HOST``, which mangled any +value that already carried a scheme -- the form Ollama's own tooling accepts. +""" + +import importlib +import os +from unittest import mock + +import pytest + +from hate_crack import main as hc_main + + +@pytest.mark.parametrize( + ("host", "expected"), + [ + # Bare host:port -- the historical default, must keep working. + ("localhost:11434", "http://localhost:11434"), + ("box:11434", "http://box:11434"), + ("127.0.0.1:11434", "http://127.0.0.1:11434"), + # Scheme already present -- previously produced http://http://... + ("http://box:11434", "http://box:11434"), + ("https://ollama.example.com", "https://ollama.example.com"), + ("https://ollama.example.com:443", "https://ollama.example.com:443"), + # Trailing slashes stripped: callers append f"{ollamaUrl}/v1". + ("http://box:11434/", "http://box:11434"), + ("https://ollama.example.com///", "https://ollama.example.com"), + ("box:11434/", "http://box:11434"), + # Whitespace and empty values fall back to the default. + (" box:11434 ", "http://box:11434"), + ("", "http://localhost:11434"), + (" ", "http://localhost:11434"), + ], +) +def test_normalize_ollama_url(host, expected): + assert hc_main._normalize_ollama_url(host) == expected + + +def test_scheme_is_never_doubled(): + """The exact regression from issue #119.""" + for host in ("http://box:11434", "https://ollama.example.com"): + assert "http://http" not in hc_main._normalize_ollama_url(host) + assert "http://https" not in hc_main._normalize_ollama_url(host) + + +def test_result_builds_a_valid_v1_endpoint(): + """llm.py derives its client base URL as f"{ollamaUrl}/v1".""" + for host in ("box:11434", "http://box:11434/", "https://x.example.com//"): + url = hc_main._normalize_ollama_url(host) + assert f"{url}/v1".count("//") == 1 + + +def test_module_level_ollamaUrl_honours_a_scheme_in_the_env(): + """Guard the wiring, not just the helper: ollamaUrl is built at import.""" + with mock.patch.dict( + os.environ, {"OLLAMA_HOST": "https://ollama.example.com"}, clear=False + ): + reloaded = importlib.reload(hc_main) + try: + assert reloaded.ollamaUrl == "https://ollama.example.com" + finally: + # Restore the module to the ambient environment for other tests. + importlib.reload(reloaded) + + +def test_module_level_ollamaUrl_defaults_to_localhost(): + env = {k: v for k, v in os.environ.items() if k != "OLLAMA_HOST"} + with mock.patch.dict(os.environ, env, clear=True): + reloaded = importlib.reload(hc_main) + try: + assert reloaded.ollamaUrl == "http://localhost:11434" + finally: + importlib.reload(reloaded) diff --git a/tests/test_ollama_wordlist_sampling.py b/tests/test_ollama_wordlist_sampling.py new file mode 100644 index 0000000..d4e193a --- /dev/null +++ b/tests/test_ollama_wordlist_sampling.py @@ -0,0 +1,461 @@ +"""Tests for the capped / evenly-sampled wordlist path in hcatOllama. + +Covers: +- ollamaMaxSampleLines config default (500) +- Small wordlist (< cap): all lines included, "Loaded N" message +- Large wordlist (> cap): exactly `cap` lines sampled, "Sampled N of M" message +- Evenly-spaced sample covers the whole file (first and last entries present) +- hash:password splitting and blank-line skipping still work +- spinner is called with the right message regardless of TTY +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from types import SimpleNamespace +from unittest import mock + +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import main as hc_main # noqa: E402 + +OLLAMA_URL = "http://localhost:11434" +MODEL = "test-model" + + +@contextmanager +def _ollama_globals(tmp_path, *, max_sample: int = 500): + rules_dir = str(tmp_path / "rules") + os.makedirs(rules_dir, exist_ok=True) + with ( + mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), + mock.patch.object(hc_main, "ollamaModel", MODEL), + mock.patch.object(hc_main, "ollamaNumCtx", 2048), + mock.patch.object(hc_main, "ollamaMaxSampleLines", max_sample), + mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), + mock.patch.object(hc_main, "hcatTuning", ""), + mock.patch.object(hc_main, "hcatPotfilePath", ""), + mock.patch.object(hc_main, "rulesDirectory", rules_dir), + mock.patch("hate_crack.main.generate_session_id", return_value="s"), + ): + yield + + +def _make_proc(rc: int = 0): + proc = mock.MagicMock() + proc.wait.return_value = rc + proc.communicate.return_value = (b"", b"") + proc.returncode = rc + return proc + + +@pytest.fixture +def env(tmp_path): + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + return SimpleNamespace(tmp_path=tmp_path, hash_file=str(hash_file)) + + +# --------------------------------------------------------------------------- +# Config default +# --------------------------------------------------------------------------- + + +def test_ollama_max_sample_lines_default(env, capsys): + """ollamaMaxSampleLines fallback behaviour is 500. + + Rather than asserting on the ambient live value (which a developer can + override in their local config.json), we verify the *behaviour*: a + wordlist with exactly 500 usable lines is loaded in full (no capping), + while one with 501 lines is capped to 500. + """ + # 500 lines → no capping, uses the "Loaded N" path + wl500 = env.tmp_path / "w500.txt" + wl500.write_text("\n".join(f"w{i:04d}" for i in range(500)) + "\n") + + with mock.patch.object(hc_main, "ollamaMaxSampleLines", 500), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen500, mock.patch.object( + hc_main, "ollamaUrl", OLLAMA_URL + ), mock.patch.object( + hc_main, "ollamaModel", MODEL + ), mock.patch.object( + hc_main, "ollamaNumCtx", 2048 + ), mock.patch.object( + hc_main, "hcatBin", "/usr/bin/hashcat" + ), mock.patch.object( + hc_main, "hcatTuning", "" + ), mock.patch.object( + hc_main, "hcatPotfilePath", "" + ), mock.patch.object( + hc_main, "rulesDirectory", str(env.tmp_path / "rules") + ), mock.patch( + "hate_crack.main.generate_session_id", return_value="s" + ), mock.patch( + "subprocess.Popen", return_value=_make_proc() + ): + import os as _os + _os.makedirs(str(env.tmp_path / "rules"), exist_ok=True) + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl500)) + + captured = capsys.readouterr() + assert "Loaded 500" in captured.out + sample_lines = gen500.call_args[0][4]["sample"].splitlines() + assert len(sample_lines) == 500 + + +# --------------------------------------------------------------------------- +# Small wordlist — no capping +# --------------------------------------------------------------------------- + + +def test_small_wordlist_loads_all(env, capsys): + """When total lines < cap, all are included and message says 'Loaded'.""" + wl = env.tmp_path / "small.txt" + wl.write_text("alpha\nbeta\ngamma\n") + + with _ollama_globals(env.tmp_path), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + assert "Loaded 3" in captured.out + # All three words must appear in the sample passed to the LLM + sample = gen.call_args[0][4]["sample"] + assert "alpha" in sample + assert "beta" in sample + assert "gamma" in sample + + +def test_small_wordlist_no_sampled_message(env, capsys): + """'Sampled N of M' must NOT appear when no capping occurred.""" + wl = env.tmp_path / "small.txt" + wl.write_text("a\nb\nc\n") + + with _ollama_globals(env.tmp_path, max_sample=500), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + assert "Sampled" not in captured.out + + +# --------------------------------------------------------------------------- +# Large wordlist — capping +# --------------------------------------------------------------------------- + + +def _make_large_wordlist(path, n: int) -> None: + """Write n unique numbered lines to path.""" + lines = "\n".join(f"word{i:06d}" for i in range(n)) + path.write_text(lines + "\n") + + +def test_large_wordlist_caps_to_max(env, capsys): + """When total > cap, exactly cap lines are sampled.""" + wl = env.tmp_path / "big.txt" + _make_large_wordlist(wl, 1000) + + captured_ctx: list[dict] = [] + + def _capture_gen(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + with _ollama_globals(env.tmp_path, max_sample=50), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture_gen + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + sample_lines = captured_ctx[0]["sample"].splitlines() + assert len(sample_lines) == 50 + + captured = capsys.readouterr() + assert "Sampled 50 of 1,000" in captured.out + + +def test_large_wordlist_covers_full_range(env): + """Evenly-spaced sample must contain entries from both the start and end of the file.""" + wl = env.tmp_path / "range.txt" + _make_large_wordlist(wl, 1000) + + captured_ctx: list[dict] = [] + + def _capture_gen(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + # Use a small cap (10) over 1000 lines so the stride is clearly 100. + with _ollama_globals(env.tmp_path, max_sample=10), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture_gen + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + sample_lines = captured_ctx[0]["sample"].splitlines() + # The sampled words should come from across the file, not just the head. + # word000000..word000099 are the first 100; word000900..word000999 are the last 100. + indices = [int(w.replace("word", "")) for w in sample_lines] + assert min(indices) < 100, "sample should include entries from the start of the file" + assert max(indices) >= 900, "sample should include entries from the end of the file" + + +# --------------------------------------------------------------------------- +# Stride < 2 regime: boundary cases for small total_usable values +# --------------------------------------------------------------------------- + + +def _sample_count(env, total: int, cap: int) -> int: + """Helper: write a wordlist with *total* lines, run hcatOllama capped to *cap*, + return the number of lines that reached the LLM.""" + wl = env.tmp_path / f"wl_{total}_{cap}.txt" + wl.write_text("\n".join(f"p{i:04d}" for i in range(total)) + "\n") + captured_ctx: list[dict] = [] + + def _capture(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + with _ollama_globals(env.tmp_path, max_sample=cap), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + if not captured_ctx: + return 0 + return len(captured_ctx[0]["sample"].splitlines()) + + +def test_stride_exact_total_equals_cap(env) -> None: + """total == cap: all lines must be returned (no capping branch taken).""" + assert _sample_count(env, 5, 5) == 5 + + +def test_stride_total_one_above_cap(env) -> None: + """total == cap + 1: exactly cap items must be sampled (stride < 2).""" + assert _sample_count(env, 4, 3) == 3 + + +def test_stride_tiny_three_cap_two(env) -> None: + """total=3, cap=2: exactly 2 items must be sampled (stride = 1.5 < 2).""" + assert _sample_count(env, 3, 2) == 2 + + +def test_stride_cap_one(env) -> None: + """cap=1 over a larger file must return exactly 1 item.""" + assert _sample_count(env, 10, 1) == 1 + + +# --------------------------------------------------------------------------- +# Invalid-cap guard — zero/negative ollamaMaxSampleLines falls back to 500 +# --------------------------------------------------------------------------- + + +def test_zero_cap_falls_back_to_default(env, capsys) -> None: + """ollamaMaxSampleLines=0 must not crash; it falls back to cap=500. + + A 10-line wordlist with cap=0 (invalid) should load all 10 lines using + the "no capping needed" path since 10 <= 500 (the fallback). + """ + wl = env.tmp_path / "zero_cap.txt" + wl.write_text("\n".join(f"pw{i}" for i in range(10)) + "\n") + + with _ollama_globals(env.tmp_path, max_sample=0), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + # Must not raise; must load all 10 words (10 <= 500 fallback) + assert "Loaded 10" in captured.out + assert len(gen.call_args[0][4]["sample"].splitlines()) == 10 + + +# --------------------------------------------------------------------------- +# hash:password splitting and blank-line skipping +# --------------------------------------------------------------------------- + + +def test_wordlist_sampling_strips_hash_prefix(env): + """hash:password lines must contribute only the plaintext in capped mode.""" + wl = env.tmp_path / "dump.txt" + # 20 lines: half with colon prefix, half plain; more than max_sample=5 + lines = [f"hash{i}:plain{i:02d}" if i % 2 == 0 else f"plain{i:02d}" for i in range(20)] + wl.write_text("\n".join(lines) + "\n") + + captured_ctx: list[dict] = [] + + def _capture_gen(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + with _ollama_globals(env.tmp_path, max_sample=5), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture_gen + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + sample = captured_ctx[0]["sample"] + # No hash prefix should appear in the sample + assert "hash" not in sample + + +def test_wordlist_sampling_skips_blank_lines(env, capsys): + """Blank lines must not count toward total or be included in the sample.""" + wl = env.tmp_path / "blanks.txt" + # 3 real words surrounded by blank lines + wl.write_text("\nalpha\n\nbeta\n\ngamma\n\n") + + with _ollama_globals(env.tmp_path, max_sample=500), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + assert "Loaded 3" in captured.out + sample = gen.call_args[0][4]["sample"] + for line in sample.splitlines(): + assert line.strip() != "", "blank lines leaked into sample" + + +# --------------------------------------------------------------------------- +# Spinner is invoked (via the TTY guard — non-TTY in test suite) +# --------------------------------------------------------------------------- + + +def test_spinner_called_with_model_message(env, capsys): + """The spinner message must include the model name (non-TTY: just print).""" + wl = env.tmp_path / "s.txt" + wl.write_text("pw\n") + + with _ollama_globals(env.tmp_path), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + assert MODEL in captured.out + assert "Generating password candidates via Ollama" in captured.out + + +# --------------------------------------------------------------------------- +# _usable_plaintext unit tests +# --------------------------------------------------------------------------- + + +def test_usable_plaintext_blank_line(): + """A blank line must return an empty string (discarded).""" + assert hc_main._usable_plaintext("") == "" + + +def test_usable_plaintext_whitespace_only(): + """A whitespace-only line must return an empty string (discarded).""" + assert hc_main._usable_plaintext(" \t ") == "" + + +def test_usable_plaintext_plain_password(): + """A plain password line (no colon) is returned stripped.""" + assert hc_main._usable_plaintext("hunter2\n") == "hunter2" + + +def test_usable_plaintext_hash_colon_password(): + """A hash:password line returns only the password portion.""" + assert hc_main._usable_plaintext("aabbcc:hunter2") == "hunter2" + + +def test_usable_plaintext_multiple_colons(): + """A line with multiple colons splits only on the first colon.""" + assert hc_main._usable_plaintext("aabbcc:p@ss:word") == "p@ss:word" + + +def test_usable_plaintext_hash_colon_empty(): + """A hash: line with no plaintext after the colon returns empty string.""" + assert hc_main._usable_plaintext("aabbcc:") == "" + + +# --------------------------------------------------------------------------- +# Shared sampling helper — used by both wordlist and cracked modes +# --------------------------------------------------------------------------- + + +def test_sample_helper_is_module_level(): + """The sampling logic lives in one shared, module-level helper.""" + assert callable(hc_main._sample_plaintext_file) + + +def test_sample_helper_caps_and_labels_source(tmp_path, capsys): + src = tmp_path / "src.txt" + src.write_text("\n".join(f"p{i:04d}" for i in range(100)) + "\n") + + sampled = hc_main._sample_plaintext_file(str(src), 10, source_label="cracked passwords") + + assert sampled is not None + assert len(sampled) == 10 + captured = capsys.readouterr() + assert "Sampled 10 of 100 passwords from cracked passwords." in captured.out + + +def test_sample_helper_returns_none_on_read_error(tmp_path, capsys): + missing = tmp_path / "nope.txt" + with mock.patch("builtins.open", side_effect=OSError("boom")): + assert hc_main._sample_plaintext_file(str(missing), 10) is None + assert "Error reading wordlist: boom" in capsys.readouterr().out + + +def test_sample_helper_empty_file_returns_empty_list(tmp_path): + src = tmp_path / "empty.txt" + src.write_text("\n\n") + assert hc_main._sample_plaintext_file(str(src), 10) == [] + + +def test_cracked_mode_uses_shared_sampling_helper(env): + """cracked mode must route through _sample_plaintext_file, not its own copy.""" + out_path = env.hash_file + ".out" + with open(out_path, "w") as f: + f.write("hash:Summer2024!\n") + + with _ollama_globals(env.tmp_path, max_sample=123), mock.patch.object( + hc_main, "_sample_plaintext_file", return_value=["Summer2024!"] + ) as sampler, mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["Winter2025!"] + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "cracked", None) + + sampler.assert_called_once_with( + out_path, 123, source_label="cracked passwords" + ) + + +def test_wordlist_mode_uses_shared_sampling_helper(env): + wl = env.tmp_path / "wl.txt" + wl.write_text("alpha\n") + + with _ollama_globals(env.tmp_path, max_sample=77), mock.patch.object( + hc_main, "_sample_plaintext_file", return_value=["alpha"] + ) as sampler, mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + sampler.assert_called_once_with(str(wl), 77) + + +def test_cracked_mode_caps_large_out_file(env, capsys): + """A large .out file gets the same evenly-spaced capping as a wordlist.""" + out_path = env.hash_file + ".out" + with open(out_path, "w") as f: + f.write("\n".join(f"h{i}:pw{i:06d}" for i in range(1000)) + "\n") + + with _ollama_globals(env.tmp_path, max_sample=25), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "cracked", None) + + sample_lines = gen.call_args[0][4]["sample"].splitlines() + assert len(sample_lines) == 25 + indices = [int(w.replace("pw", "")) for w in sample_lines] + assert min(indices) < 100 + assert max(indices) >= 900 + assert "Sampled 25 of 1,000 passwords from cracked passwords." in capsys.readouterr().out diff --git a/tests/test_omen_attack.py b/tests/test_omen_attack.py index ef33507..7239df1 100644 --- a/tests/test_omen_attack.py +++ b/tests/test_omen_attack.py @@ -277,8 +277,10 @@ class TestOmenAttackHandler: def test_use_existing_model(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "0"]), ): from hate_crack.attacks import omen_attack @@ -289,8 +291,10 @@ class TestOmenAttackHandler: def test_train_new_model_with_wordlist_pick(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["2", "1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["1", "", "0"]), ): from hate_crack.attacks import omen_attack @@ -302,8 +306,22 @@ class TestOmenAttackHandler: def test_cancel_aborts(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["3"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="99"), + ): + from hate_crack.attacks import omen_attack + + omen_attack(ctx) + ctx.hcatOmenTrain.assert_not_called() + ctx.hcatOmen.assert_not_called() + + def test_escape_cancels(self, tmp_path): + """None from interactive_menu (Escape) cancels the attack.""" + ctx = self._make_ctx(tmp_path, model_valid=True) + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value=None), ): from hate_crack.attacks import omen_attack @@ -337,19 +355,23 @@ class TestOmenAttackHandler: def test_custom_path_for_training(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=False) self._setup_rules_dir(tmp_path) + ctx.select_file_with_autocomplete.return_value = "/custom/wordlist.txt" with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["p", "/custom/wordlist.txt", "", "0"] + "builtins.input", side_effect=["p", "", "0"] ): from hate_crack.attacks import omen_attack omen_attack(ctx) + ctx.select_file_with_autocomplete.assert_called_once() ctx.hcatOmenTrain.assert_called_once_with("/custom/wordlist.txt") def test_rules_passed_to_hcatOmen(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "1"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "1"]), ): from hate_crack.attacks import omen_attack @@ -361,8 +383,10 @@ class TestOmenAttackHandler: def test_multiple_rule_chains_spawn_multiple_calls(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule", "dive.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "1,2"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "1,2"]), ): from hate_crack.attacks import omen_attack @@ -372,8 +396,10 @@ class TestOmenAttackHandler: def test_cancel_from_rules_aborts(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "99"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "99"]), ): from hate_crack.attacks import omen_attack @@ -383,8 +409,10 @@ class TestOmenAttackHandler: def test_no_rules_passes_empty_chain(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "0"]), ): from hate_crack.attacks import omen_attack diff --git a/tests/test_pipal_e2e.py b/tests/test_pipal_e2e.py new file mode 100644 index 0000000..9b3139f --- /dev/null +++ b/tests/test_pipal_e2e.py @@ -0,0 +1,211 @@ +"""End-to-end tests for ``hate_crack.main.pipal``. + +These are hermetic e2e tests: instead of requiring a real Ruby + pipal.rb +install, they drop a small fake ``pipal`` executable on disk that consumes the +same CLI (`` -t --output ``) and emits a pipal-shaped +report. ``pipal()`` is then driven through its real code path — writing the +``.passwords`` file (including ``$HEX[...]`` decoding), spawning the subprocess +via ``subprocess.Popen(..., shell=True)``, and parsing the ``Top N base words`` +section back out. + +A real-tool variant is gated behind ``HATE_CRACK_PIPAL_REAL=1`` + +``HATE_CRACK_PIPAL_PATH`` for anyone who wants to run against genuine pipal.rb. +""" + +import os +import stat +import sys +import textwrap +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _get_main_module(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + os.environ["HATE_CRACK_SKIP_INIT"] = "1" + import hate_crack.main as m # noqa: PLC0415 + + return m + + +# A fake pipal that mimics the real tool closely enough for the parser: +# base word = password lowercased with trailing non-alphabetic characters +# stripped, ranked by descending frequency, and only the top -t entries are +# emitted under the "Top N base words" header. +_FAKE_PIPAL = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import re + import sys + + args = sys.argv[1:] + pw_file = args[0] + top = 10 + out = None + i = 1 + while i < len(args): + if args[i] in ("-t", "--top"): + top = int(args[i + 1]); i += 2 + elif args[i] in ("-o", "--output"): + out = args[i + 1]; i += 2 + elif args[i] == "--output": + out = args[i + 1]; i += 2 + else: + i += 1 + + counts = {} + with open(pw_file, encoding="utf-8", errors="replace") as fh: + for line in fh: + pw = line.rstrip("\\n") + if not pw: + continue + base = re.sub(r"[^a-zA-Z]+$", "", pw).lower() + if not base: + continue + counts[base] = counts.get(base, 0) + 1 + + ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) + total = sum(counts.values()) + + lines = [] + lines.append("Basic Results") + lines.append("") + lines.append("Total entries = %d" % total) + lines.append("") + lines.append("Top %d base words" % top) + for word, c in ranked[:top]: + pct = 100.0 * c / total if total else 0.0 + lines.append("%s = %d (%.2f%%)" % (word, c, pct)) + lines.append("") + + with open(out, "w", encoding="utf-8") as fh: + fh.write("\\n".join(lines) + "\\n") + """ +) + + +def _install_fake_pipal(tmp_path: Path) -> Path: + fake = tmp_path / "pipal" + fake.write_text(_FAKE_PIPAL) + fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return fake + + +def _run_pipal(monkeypatch, m, tmp_path, cracked_lines, pipal_count): + """Wire up main-module globals and run the real ``pipal()``.""" + fake = _install_fake_pipal(tmp_path) + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") + (tmp_path / "hashes.txt.out").write_text("".join(cracked_lines)) + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", pipal_count) + # hcatHashFile / hcatHashType only exist after main() runs; create them. + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) # not NTLM + return m.pipal(), hashfile + + +class TestPipalE2E: + def test_returns_top_basewords(self, monkeypatch, tmp_path, capsys): + m = _get_main_module() + cracked = [ + "hash1:password1\n", + "hash2:password2\n", + "hash3:Password!\n", + "hash4:summer2021\n", + "hash5:summer99\n", + "hash6:winter1\n", + ] + result, hashfile = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=3) + + assert result == ["password", "summer", "winter"] + # pipal report and intermediate passwords file were produced + assert (Path(str(hashfile) + ".pipal")).is_file() + assert (Path(str(hashfile) + ".passwords")).is_file() + + def test_passwords_file_decodes_hex(self, monkeypatch, tmp_path): + m = _get_main_module() + # 70617373776f7264 == "password" + cracked = [ + "hash1:$HEX[70617373776f7264]\n", + "hash2:hunter2\n", + ] + _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=2) + + pw_path = tmp_path / "hashes.txt.passwords" + contents = pw_path.read_text(encoding="utf-8").splitlines() + assert "password" in contents # $HEX decoded, not written literally + assert "hunter2" in contents + assert not any(line.startswith("$HEX[") for line in contents) + + def test_no_cracked_output_returns_empty(self, monkeypatch, tmp_path): + m = _get_main_module() + fake = _install_fake_pipal(tmp_path) + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") # note: no .out file created + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + assert m.pipal() == [] + + def test_missing_pipal_path_returns_none(self, monkeypatch, tmp_path): + m = _get_main_module() + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") + (tmp_path / "hashes.txt.out").write_text("hash1:password1\n") + + monkeypatch.setattr(m, "pipalPath", str(tmp_path / "does-not-exist")) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + assert m.pipal() is None + + def test_handles_shell_metacharacters_in_path(self, monkeypatch, tmp_path): + """The subprocess call must not use a shell (no command injection). + + A hash-file path containing shell metacharacters would, under the old + ``shell=True`` string command, either break or execute the injected + fragment. With list-form Popen it is handled as a literal path. + """ + m = _get_main_module() + weird_dir = tmp_path / "a b; touch INJECTED" + weird_dir.mkdir() + cracked = ["hash1:password1\n", "hash2:summer2021\n"] + + fake = _install_fake_pipal(tmp_path) + hashfile = weird_dir / "hashes.txt" + hashfile.write_text("dummy\n") + (weird_dir / "hashes.txt.out").write_text("".join(cracked)) + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + + result = m.pipal() + + assert result == ["password", "summer"] + # the injected `touch INJECTED` must NOT have run + assert not (tmp_path / "INJECTED").exists() + assert not Path("INJECTED").exists() + + def test_fewer_basewords_than_count(self, monkeypatch, tmp_path): + """Real-world case: fewer unique base words than ``pipal_count``. + + A small cracked set should still surface the base words it *does* + have, rather than silently returning nothing. + """ + m = _get_main_module() + cracked = [ + "hash1:password1\n", + "hash2:summer2021\n", + "hash3:winter1\n", + ] + # default pipal_count (10) is larger than the 3 available base words + result, _ = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=10) + assert result == ["password", "summer", "winter"] diff --git a/tests/test_progress_spinner.py b/tests/test_progress_spinner.py new file mode 100644 index 0000000..c54c68b --- /dev/null +++ b/tests/test_progress_spinner.py @@ -0,0 +1,151 @@ +"""Tests for hate_crack/progress.py — the generic spinner context manager.""" + +from __future__ import annotations + +import io +import re +import sys +import time +from unittest import mock + +import pytest + +from hate_crack.progress import spinner + + +# --------------------------------------------------------------------------- +# TTY guard: non-TTY path must print message once and not start a thread +# --------------------------------------------------------------------------- + + +def test_non_tty_prints_message( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """In a non-TTY environment the message is printed once and no thread runs.""" + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + + with spinner("Working..."): + pass + + captured = capsys.readouterr() + assert "Working..." in captured.out + + +def test_non_tty_no_extra_thread() -> None: + """Spinner in non-TTY context must never construct a Thread.""" + buf = io.StringIO() + buf.isatty = lambda: False # type: ignore[attr-defined] + + with mock.patch("sys.stdout", buf), mock.patch( + "hate_crack.progress.threading.Thread" + ) as mock_thread: + with spinner("No threads please"): + pass + mock_thread.assert_not_called() + + +# --------------------------------------------------------------------------- +# TTY path: spinner starts a thread, clears the line on exit +# --------------------------------------------------------------------------- + + +def test_tty_clears_line_on_exit() -> None: + """After the context exits the spinner line must be erased.""" + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + with mock.patch("sys.stdout", buf): + with spinner("Testing..."): + pass # no sleep needed — the finally block writes the escape regardless + + output = buf.getvalue() + # Line clear sequence must be present somewhere after the spinner started. + assert "\033[2K\r" in output or ("\033[2K" in output) + + +def test_tty_shows_elapsed_seconds() -> None: + """The spinner must render the elapsed-seconds counter in the expected format. + + time.monotonic is patched so the test is deterministic regardless of + scheduling: the first call returns 0.0 (start), the second returns 3.0 + (first tick), giving a stable "3s" label without sleeping. + """ + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + # Patch monotonic: start=0.0, then 3.0 at the first tick, then keep + # returning 3.0 so stop_event.wait() ends quickly. + mono_values = iter([0.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0]) + + with mock.patch("sys.stdout", buf), mock.patch( + "hate_crack.progress.time.monotonic", side_effect=mono_values + ): + with spinner("Counting..."): + pass # body exits immediately; the thread gets one tick then stops + + output = buf.getvalue() + assert "Counting..." in output + # At least one frame must match the "Ns" elapsed format (e.g. "3s"). + assert re.search(r"\d+s", output), f"Expected elapsed counter in output: {output!r}" + + +def test_tty_exception_still_clears_line() -> None: + """Spinner must clear the line even when the body raises.""" + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + with mock.patch("sys.stdout", buf): + with pytest.raises(ValueError): + with spinner("Will raise"): + raise ValueError("boom") + + output = buf.getvalue() + assert "\033[2K\r" in output or ("\033[2K" in output) + + +def test_spinner_does_not_swallow_exception() -> None: + """The spinner context manager must re-raise exceptions from the body.""" + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + with mock.patch("sys.stdout", buf): + with pytest.raises(RuntimeError, match="test error"): + with spinner("Should propagate"): + raise RuntimeError("test error") + + +# --------------------------------------------------------------------------- +# Teardown robustness: line must be cleared even if join() is interrupted +# --------------------------------------------------------------------------- + + +def test_tty_clears_line_even_if_join_raises() -> None: + """Line-clear must happen even when thread.join() raises (e.g. DoubleInterrupt). + + The fix moves the write("\033[2K\r") *before* thread.join() so an + exception during join cannot prevent the terminal from being cleaned up. + """ + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + class FakeThread: + def __init__(self, *args, **kwargs) -> None: + pass + + def start(self) -> None: + pass + + def join(self, *args, **kwargs) -> None: + raise KeyboardInterrupt("simulated double Ctrl-C during join") + + with mock.patch("hate_crack.progress.threading.Thread", FakeThread), mock.patch( + "sys.stdout", buf + ): + with pytest.raises(KeyboardInterrupt): + with spinner("Robust teardown"): + pass + + output = buf.getvalue() + assert "\033[2K\r" in output, ( + "Line-clear escape sequence must be written before join() is called" + ) diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py deleted file mode 100644 index 54abe21..0000000 --- a/tests/test_pull_ollama_model.py +++ /dev/null @@ -1,684 +0,0 @@ -"""Unit tests for _pull_ollama_model helper, hcatOllama steps A-C, and ollama_attack handler.""" - -import io -import json -import os -import urllib.error -import urllib.request -from contextlib import contextmanager -from types import SimpleNamespace -from unittest import mock - -import pytest - -os.environ["HATE_CRACK_SKIP_INIT"] = "1" -from hate_crack import main as hc_main # noqa: E402 - - -# --------------------------------------------------------------------------- -# Shared test infrastructure -# --------------------------------------------------------------------------- - -OLLAMA_URL = "http://localhost:11434" -MODEL = "llama3.2" - - -@pytest.fixture -def ollama_env(tmp_path): - """Create the filesystem layout hcatOllama expects.""" - hash_file = tmp_path / "hashes.txt" - hash_file.touch() - - wordlist = tmp_path / "sample.txt" - wordlist.write_text("password\n123456\nletmein\n") - - return SimpleNamespace( - tmp_path=tmp_path, - hash_file=str(hash_file), - wordlist=str(wordlist), - ) - - -@contextmanager -def ollama_globals(tmp_path, tuning="", potfile=""): - """Patch the hc_main globals that hcatOllama reads.""" - rules_dir = str(tmp_path / "rules") - os.makedirs(rules_dir, exist_ok=True) - with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \ - mock.patch.object(hc_main, "ollamaModel", MODEL), \ - mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ - mock.patch.object(hc_main, "hcatTuning", tuning), \ - mock.patch.object(hc_main, "hcatPotfilePath", potfile), \ - mock.patch.object(hc_main, "hate_path", str(tmp_path)), \ - mock.patch.object(hc_main, "rulesDirectory", rules_dir), \ - mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - yield - - -def _make_proc(wait_return=0): - """Create a mock subprocess that works with both wait() and communicate().""" - proc = mock.MagicMock() - proc.wait.return_value = wait_return - proc.communicate.return_value = (b"", b"") - proc.returncode = wait_return - return proc - - -def _generate_response(passwords): - """Build a fake urlopen context-manager that returns an Ollama /api/generate JSON body.""" - body = json.dumps({"response": "\n".join(passwords)}).encode() - resp = mock.MagicMock() - resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) - resp.__exit__ = mock.Mock(return_value=False) - return resp - - -def _urlopen_with_response(passwords): - """Return a urlopen mock that always succeeds with the given passwords.""" - return mock.patch( - "hate_crack.main.urllib.request.urlopen", - return_value=_generate_response(passwords), - ) - - -# --------------------------------------------------------------------------- -# _pull_ollama_model tests -# --------------------------------------------------------------------------- - -class TestPullOllamaModel: - """Tests for _pull_ollama_model().""" - - OLLAMA_URL = "http://localhost:11434" - MODEL = "llama3.2" - - def _make_stream_response(self, statuses): - """Build a fake streaming response (newline-delimited JSON).""" - lines = [json.dumps({"status": s}).encode() + b"\n" for s in statuses] - return io.BytesIO(b"".join(lines)) - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_successful_pull(self, mock_urlopen, capsys): - mock_urlopen.return_value.__enter__ = mock.Mock( - return_value=self._make_stream_response( - ["pulling manifest", "downloading sha256:abc123", "success"] - ) - ) - mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is True - captured = capsys.readouterr() - assert "not found locally" in captured.out - assert "Successfully pulled" in captured.out - assert "pulling manifest" in captured.out - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_http_error(self, mock_urlopen, capsys): - mock_urlopen.side_effect = urllib.error.HTTPError( - url="http://localhost:11434/api/pull", - code=500, - msg="Internal Server Error", - hdrs=None, - fp=None, - ) - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is False - captured = capsys.readouterr() - assert "HTTP 500" in captured.out - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_url_error(self, mock_urlopen, capsys): - mock_urlopen.side_effect = urllib.error.URLError("Connection refused") - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is False - captured = capsys.readouterr() - assert "Could not connect" in captured.out - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_generic_exception(self, mock_urlopen, capsys): - mock_urlopen.side_effect = RuntimeError("unexpected") - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is False - captured = capsys.readouterr() - assert "unexpected" in captured.out - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_handles_empty_status_lines(self, mock_urlopen, capsys): - """Blank lines and missing status keys should not crash.""" - lines = b'{"status": "downloading"}\n\n{"other": "data"}\n' - mock_urlopen.return_value.__enter__ = mock.Mock( - return_value=io.BytesIO(lines) - ) - mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is True - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_request_payload(self, mock_urlopen): - """Verify the pull request sends the correct model name and stream=True.""" - mock_urlopen.return_value.__enter__ = mock.Mock( - return_value=self._make_stream_response(["success"]) - ) - mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) - - hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - call_args = mock_urlopen.call_args - req = call_args[0][0] - body = json.loads(req.data.decode("utf-8")) - assert body["name"] == self.MODEL - assert body["stream"] is True - assert req.full_url == f"{self.OLLAMA_URL}/api/pull" - - -# --------------------------------------------------------------------------- -# hcatOllama 404-retry integration tests -# --------------------------------------------------------------------------- - -class TestHcatOllama404Retry: - """Test that hcatOllama auto-pulls on 404 and retries the generate call.""" - - OLLAMA_URL = "http://localhost:11434" - MODEL = "llama3.2" - - def _generate_response(self, passwords): - body = json.dumps({"response": "\n".join(passwords)}).encode() - return io.BytesIO(body) - - def _setup_env(self, tmp_path): - """Create the files/dirs hcatOllama needs before it hits the API.""" - hash_file = str(tmp_path / "hashes.txt") - open(hash_file, "w").close() - - # Create a small wordlist for "wordlist" mode - wordlist = str(tmp_path / "sample.txt") - with open(wordlist, "w") as f: - f.write("password\n123456\nletmein\n") - - return hash_file, wordlist - - @mock.patch("hate_crack.main._pull_ollama_model") - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_404_triggers_pull_then_retries(self, mock_urlopen, mock_pull, capsys, tmp_path): - """A 404 on generate should trigger a pull, then retry successfully.""" - hash_file, wordlist = self._setup_env(tmp_path) - - # First call: 404, second call: success - generate_ok = mock.MagicMock() - generate_ok.__enter__ = mock.Mock( - return_value=self._generate_response(["Password1", "Summer2024"]) - ) - generate_ok.__exit__ = mock.Mock(return_value=False) - - mock_urlopen.side_effect = [ - urllib.error.HTTPError( - url=f"{self.OLLAMA_URL}/api/generate", - code=404, - msg="Not Found", - hdrs=None, - fp=None, - ), - generate_ok, - ] - mock_pull.return_value = True - - rules_dir = str(tmp_path / "rules") - os.makedirs(rules_dir, exist_ok=True) - with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ - mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ - mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ - mock.patch.object(hc_main, "hcatTuning", ""), \ - mock.patch.object(hc_main, "hcatPotfilePath", ""), \ - mock.patch.object(hc_main, "hate_path", str(tmp_path)), \ - mock.patch.object(hc_main, "rulesDirectory", rules_dir), \ - mock.patch("hate_crack.main.generate_session_id", return_value="test_session"), \ - mock.patch("subprocess.Popen") as mock_popen: - - proc = _make_proc() - mock_popen.return_value = proc - - hc_main.hcatOllama("0", hash_file, "wordlist", wordlist) - - mock_pull.assert_called_once_with(self.OLLAMA_URL, self.MODEL) - # urlopen called twice: first 404, then retry - assert mock_urlopen.call_count == 2 - - @mock.patch("hate_crack.main._pull_ollama_model") - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_404_pull_fails_aborts(self, mock_urlopen, mock_pull, capsys, tmp_path): - """If pull fails after 404, hcatOllama should abort gracefully.""" - hash_file, wordlist = self._setup_env(tmp_path) - - mock_urlopen.side_effect = urllib.error.HTTPError( - url=f"{self.OLLAMA_URL}/api/generate", - code=404, - msg="Not Found", - hdrs=None, - fp=None, - ) - mock_pull.return_value = False - - with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ - mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ - mock.patch.object(hc_main, "hate_path", str(tmp_path)): - - hc_main.hcatOllama("0", hash_file, "wordlist", wordlist) - - captured = capsys.readouterr() - assert "Could not pull model" in captured.out - mock_pull.assert_called_once() - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_non_404_http_error_propagates(self, mock_urlopen, capsys, tmp_path): - """Non-404 HTTP errors should not trigger a pull attempt.""" - hash_file, wordlist = self._setup_env(tmp_path) - - mock_urlopen.side_effect = urllib.error.HTTPError( - url=f"{self.OLLAMA_URL}/api/generate", - code=500, - msg="Internal Server Error", - hdrs=None, - fp=None, - ) - - with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ - mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ - mock.patch.object(hc_main, "hate_path", str(tmp_path)): - - hc_main.hcatOllama("0", hash_file, "wordlist", wordlist) - - captured = capsys.readouterr() - # HTTPError is a subclass of URLError, so it hits the URLError handler - assert "Could not connect to Ollama" in captured.out or "Error calling Ollama API" in captured.out - assert "500" in captured.out - - -# --------------------------------------------------------------------------- -# Step A: Mode routing, prompt construction, early-return error paths -# --------------------------------------------------------------------------- - -class TestHcatOllamaModeRouting: - """Test mode selection, prompt building, and early-return errors.""" - - def test_unknown_mode_prints_error(self, ollama_env, capsys): - """Bad mode string → error message, no API call.""" - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: - hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", "") - - captured = capsys.readouterr() - assert "Unknown LLM generation mode" in captured.out - mock_url.assert_not_called() - - def test_missing_wordlist_prints_error(self, ollama_env, capsys): - """Non-existent wordlist path → error, no API call.""" - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", - "/no/such/wordlist.txt", - ) - - captured = capsys.readouterr() - assert "Wordlist not found" in captured.out - mock_url.assert_not_called() - - def test_wordlist_mode_reads_all_lines(self, ollama_env, capsys): - """All non-blank lines from the wordlist should appear in the prompt.""" - # Write 600 lines to the wordlist - big_wordlist = ollama_env.tmp_path / "big.txt" - big_wordlist.write_text("\n".join(f"pass{i}" for i in range(600)) + "\n") - - captured_payload = {} - - def fake_urlopen(req, **kwargs): - captured_payload["data"] = json.loads(req.data.decode()) - return _generate_response(["Password1"]) - - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ - mock.patch("subprocess.Popen") as mock_popen: - proc = _make_proc() - mock_popen.return_value = proc - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", str(big_wordlist), - ) - - prompt_text = captured_payload["data"]["prompt"] - # All lines should be included in the prompt - assert "pass0" in prompt_text - assert "pass499" in prompt_text - assert "pass599" in prompt_text - - def test_target_mode_includes_context_in_prompt(self, ollama_env, capsys): - """Company, industry, and location should appear in the prompt.""" - captured_payload = {} - - def fake_urlopen(req, **kwargs): - captured_payload["data"] = json.loads(req.data.decode()) - return _generate_response(["AcmeCorp2024"]) - - target_info = { - "company": "AcmeCorp", - "industry": "Finance", - "location": "New York", - } - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ - mock.patch("subprocess.Popen") as mock_popen: - proc = _make_proc() - mock_popen.return_value = proc - hc_main.hcatOllama( - "0", ollama_env.hash_file, "target", target_info, - ) - - prompt_text = captured_payload["data"]["prompt"] - assert "AcmeCorp" in prompt_text - assert "Finance" in prompt_text - assert "New York" in prompt_text - - -# --------------------------------------------------------------------------- -# Step B: Candidate filtering / post-processing -# --------------------------------------------------------------------------- - -class TestHcatOllamaCandidateFiltering: - """Test regex stripping, blank-line removal, 128-char limit, empty-result handling.""" - - def _run_with_response(self, ollama_env, response_text): - """Run hcatOllama with a canned Ollama response_text, return candidates file content.""" - body = json.dumps({"response": response_text}).encode() - resp = mock.MagicMock() - resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) - resp.__exit__ = mock.Mock(return_value=False) - - candidates_path = f"{ollama_env.hash_file}.ollama_candidates" - captured = {} - - def capture_popen(cmd, **kwargs): - # Read the candidates file before hashcat "runs" (cleanup happens after) - if os.path.isfile(candidates_path): - with open(candidates_path) as f: - captured["content"] = f.read() - return _make_proc() - - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp), \ - mock.patch("subprocess.Popen", side_effect=capture_popen): - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - return captured.get("content") - - def test_strips_numeric_dot_prefix(self, ollama_env): - content = self._run_with_response(ollama_env, "1. Password1\n2. Summer2024") - assert content is not None - lines = [l for l in content.strip().split("\n") if l] - assert "Password1" in lines - assert "Summer2024" in lines - # No line should start with a digit+dot - for line in lines: - assert not line.startswith("1.") - assert not line.startswith("2.") - - def test_strips_dash_and_asterisk_prefix(self, ollama_env): - content = self._run_with_response(ollama_env, "- foo\n* bar") - assert content is not None - lines = [l for l in content.strip().split("\n") if l] - assert "foo" in lines - assert "bar" in lines - - def test_skips_blank_lines(self, ollama_env): - content = self._run_with_response(ollama_env, "alpha\n\n\nbeta\n\n") - assert content is not None - lines = [l for l in content.strip().split("\n") if l] - assert lines == ["alpha", "beta"] - - def test_rejects_over_128_chars(self, ollama_env): - long_pw = "A" * 129 - content = self._run_with_response(ollama_env, f"short\n{long_pw}\nkeep") - assert content is not None - lines = [l for l in content.strip().split("\n") if l] - assert "short" in lines - assert "keep" in lines - assert long_pw not in lines - - def test_accepts_exactly_128_chars(self, ollama_env): - exact_pw = "B" * 128 - content = self._run_with_response(ollama_env, f"{exact_pw}\nother") - assert content is not None - lines = [l for l in content.strip().split("\n") if l] - assert exact_pw in lines - - def test_empty_response_prints_error(self, ollama_env, capsys): - self._run_with_response(ollama_env, "") - captured = capsys.readouterr() - assert "no usable" in captured.out.lower() - - def test_missing_response_key(self, ollama_env, capsys): - """If the JSON has no 'response' key, treat as empty.""" - body = json.dumps({"other": "stuff"}).encode() - resp = mock.MagicMock() - resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) - resp.__exit__ = mock.Mock(return_value=False) - - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp): - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - captured = capsys.readouterr() - assert "no usable" in captured.out.lower() - - -# --------------------------------------------------------------------------- -# Step B: API error paths (non-404) -# --------------------------------------------------------------------------- - -class TestHcatOllamaApiErrors: - """Test connection errors and generic exceptions during the generate call.""" - - def test_url_error_prints_connection_error(self, ollama_env, capsys): - with ollama_globals(ollama_env.tmp_path), \ - mock.patch( - "hate_crack.main.urllib.request.urlopen", - side_effect=urllib.error.URLError("Connection refused"), - ): - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - captured = capsys.readouterr() - assert "Could not connect" in captured.out - assert "Ensure Ollama is running" in captured.out - - def test_generic_exception_prints_error(self, ollama_env, capsys): - with ollama_globals(ollama_env.tmp_path), \ - mock.patch( - "hate_crack.main.urllib.request.urlopen", - side_effect=RuntimeError("boom"), - ): - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - captured = capsys.readouterr() - assert "Error calling Ollama API" in captured.out - - def test_generate_request_payload(self, ollama_env): - """Verify the /api/generate request has correct URL, model, stream=false.""" - captured_req = {} - - def fake_urlopen(req, **kwargs): - captured_req["url"] = req.full_url - captured_req["body"] = json.loads(req.data.decode()) - return _generate_response(["Password1"]) - - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen", side_effect=fake_urlopen), \ - mock.patch("subprocess.Popen") as mock_popen: - proc = _make_proc() - mock_popen.return_value = proc - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - assert captured_req["url"] == f"{OLLAMA_URL}/api/generate" - assert captured_req["body"]["model"] == MODEL - assert captured_req["body"]["stream"] is False - assert len(captured_req["body"]["prompt"]) > 0 - - -# --------------------------------------------------------------------------- -# Step C: Hashcat command construction -# --------------------------------------------------------------------------- - -class TestHcatOllamaHashcatCommand: - """Test hashcat command flags and process handling.""" - - def _run_full(self, ollama_env, tuning="", potfile=""): - """Run hcatOllama through all steps, returning the list of Popen calls.""" - popen_calls = [] - - def track_popen(cmd, **kwargs): - popen_calls.append((list(cmd), dict(kwargs))) - return _make_proc() - - with ollama_globals(ollama_env.tmp_path, tuning=tuning, potfile=potfile), \ - _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", side_effect=track_popen), \ - mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - hc_main.hcatOllama( - "1000", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - return popen_calls - - def test_hashcat_command_structure(self, ollama_env): - """First Popen call should be a straight wordlist attack with candidates file, - followed by rule-based attacks.""" - calls = self._run_full(ollama_env) - assert len(calls) >= 1, "Expected at least one Popen call (hashcat)" - - # First call: base wordlist attack (no rules) - hashcat_cmd = calls[0][0] - assert hashcat_cmd[0] == "/usr/bin/hashcat" - assert "-m" in hashcat_cmd - m_idx = hashcat_cmd.index("-m") - assert hashcat_cmd[m_idx + 1] == "1000" - # Should use the candidates wordlist file - candidates_path = f"{ollama_env.hash_file}.ollama_candidates" - assert candidates_path in hashcat_cmd - # First call should NOT have mask attack flags - assert "-a" not in hashcat_cmd - assert "--markov-hcstat2" not in " ".join(hashcat_cmd) - assert "--increment" not in hashcat_cmd - # First call should NOT have -r (rule) flag - assert "-r" not in hashcat_cmd - - # Subsequent calls: rule-based attacks - if len(calls) > 1: - for rule_cmd, _ in calls[1:]: - assert "-r" in rule_cmd - assert candidates_path in rule_cmd - - def test_hashcat_includes_tuning(self, ollama_env): - """-w 3 tuning flag should be appended to hashcat cmd.""" - calls = self._run_full(ollama_env, tuning="-w 3") - hashcat_cmd = calls[0][0] - assert "-w" in hashcat_cmd - w_idx = hashcat_cmd.index("-w") - assert hashcat_cmd[w_idx + 1] == "3" - - def test_hashcat_includes_potfile(self, ollama_env): - """--potfile-path should be present when hcatPotfilePath is set.""" - calls = self._run_full(ollama_env, potfile="/tmp/test.potfile") - hashcat_cmd = calls[0][0] - potfile_flags = [f for f in hashcat_cmd if "--potfile-path=" in f] - assert len(potfile_flags) == 1 - assert potfile_flags[0] == "--potfile-path=/tmp/test.potfile" - - def test_hashcat_keyboard_interrupt(self, ollama_env, capsys): - """KeyboardInterrupt during hashcat should kill the process.""" - proc = mock.MagicMock() - proc.pid = 99999 - proc.wait.side_effect = KeyboardInterrupt() - - with ollama_globals(ollama_env.tmp_path), \ - _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", return_value=proc), \ - mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - captured = capsys.readouterr() - assert "Killing PID" in captured.out - proc.kill.assert_called_once() - - -# --------------------------------------------------------------------------- -# Step F: Cleanup -# --------------------------------------------------------------------------- - -class TestHcatOllamaWordlistPersistence: - """Test that the generated wordlist file persists after the run.""" - - def test_candidates_file_persists(self, ollama_env): - """ollama_candidates wordlist should remain after the run.""" - with ollama_globals(ollama_env.tmp_path), \ - _urlopen_with_response(["Password1"]), \ - mock.patch("subprocess.Popen", return_value=_make_proc()), \ - mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - candidates_path = f"{ollama_env.hash_file}.ollama_candidates" - assert os.path.isfile(candidates_path), "candidates wordlist should persist" - - -# --------------------------------------------------------------------------- -# attacks.py: ollama_attack() UI handler -# --------------------------------------------------------------------------- - -class TestOllamaAttackHandler: - """Test the ollama_attack(ctx) menu handler in attacks.py.""" - - def _make_ctx(self, **overrides): - """Build a mock ctx with the attributes ollama_attack() reads.""" - ctx = mock.MagicMock() - ctx.hcatHashType = "0" - ctx.hcatHashFile = "/tmp/hashes.txt" - ctx.hcatWordlists = "/tmp/wordlists" - for k, v in overrides.items(): - setattr(ctx, k, v) - return ctx - - def test_target_mode(self): - """ollama_attack prompts for company/industry/location and calls hcatOllama.""" - from hate_crack.attacks import ollama_attack - - ctx = self._make_ctx() - with mock.patch("builtins.input", side_effect=["AcmeCorp", "Finance", "NYC"]): - ollama_attack(ctx) - - ctx.hcatOllama.assert_called_once() - call_args = ctx.hcatOllama.call_args - assert call_args[0][2] == "target" - target_info = call_args[0][3] - assert target_info["company"] == "AcmeCorp" - assert target_info["industry"] == "Finance" - assert target_info["location"] == "NYC" - diff --git a/tests/test_rule_tools.py b/tests/test_rule_tools.py index 051f01b..19b5b90 100644 --- a/tests/test_rule_tools.py +++ b/tests/test_rule_tools.py @@ -26,7 +26,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) ctx.rules_cleanup.assert_called_once_with(str(infile), str(outfile)) @@ -40,7 +41,8 @@ class TestRuleCleanupHandler: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) ctx.rules_cleanup.assert_not_called() @@ -49,7 +51,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) assert "[+] Done." in capsys.readouterr().out @@ -59,7 +62,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) assert "[!] Cleanup failed." in capsys.readouterr().out @@ -70,7 +74,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) ctx.rules_optimize.assert_called_once_with(str(infile), str(outfile)) @@ -84,7 +89,8 @@ class TestRuleOptimizeHandler: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) ctx.rules_optimize.assert_not_called() @@ -93,7 +99,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) assert "[+] Done." in capsys.readouterr().out @@ -103,7 +110,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) assert "[!] Optimize failed." in capsys.readouterr().out @@ -114,7 +122,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_cleanup.assert_called_once() ctx.rules_optimize.assert_called_once() @@ -125,7 +134,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "out.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_optimize.assert_not_called() @@ -139,7 +149,8 @@ class TestRuleCleanupAndOptimize: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_cleanup.assert_not_called() @@ -155,7 +166,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) assert captured_tmp, "rules_cleanup should have been called" assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" @@ -172,7 +184,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "out.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) if captured_tmp: assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" @@ -182,7 +195,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) assert "[+] Done." in capsys.readouterr().out