From f0b512a0796baab15fb240ac4986e480c3bbeee3 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 18 Feb 2026 09:32:40 -0500 Subject: [PATCH] feat: add startup version check, fix PassGPT MPS/output issues, hide menu without ML deps - Add optional startup version check against GitHub releases (check_for_updates config option) - Add packaging dependency for version comparison - Fix PassGPT OOM on MPS by capping batch size to 64 and setting memory watermark limits - Fix PassGPT output having spaces between every character - Hide PassGPT menu item (17) unless torch/transformers are installed - Fix mypy errors in passgpt_generate.py with type: ignore comments - Update README with version check docs, optional ML deps section, and PassGPT CLI options - Add test_version_check.py with 8 tests covering update check behavior Co-Authored-By: Claude Opus 4.6 --- README.md | 65 +++++++++++++----- hate_crack.py | 3 +- hate_crack/main.py | 6 +- hate_crack/passgpt_generate.py | 35 ++++++++-- tests/test_omen_attack.py | 66 +++++++++++++++--- tests/test_version_check.py | 119 +++++++++++++++++++++++++++++++++ 6 files changed, 264 insertions(+), 30 deletions(-) create mode 100644 tests/test_version_check.py diff --git a/README.md b/README.md index 08e86d1..61890a0 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,17 @@ Make it executable: chmod +x .git/hooks/pre-push ``` +### Optional Dependencies + +The optional `[ml]` group includes ML/AI features: +- **torch** - PyTorch deep learning framework (for PassGPT attack) +- **transformers** - HuggingFace transformers library (for GPT-2 models) + +Install with: +```bash +uv pip install -e ".[ml]" +``` + ### Dev Dependencies The optional `[dev]` group includes: @@ -428,6 +439,20 @@ The LLM Attack (option 15) uses Ollama to generate password candidates. Configur - **`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. +#### Automatic Update Checks + +hate_crack can automatically check GitHub for newer releases on startup. This feature is controlled by the `check_for_updates` config option: + +```json +{ + "check_for_updates": true +} +``` + +- **`check_for_updates`** — Enable automatic version checks on startup (default: `true`). +- When enabled, hate_crack fetches the latest release info from GitHub and displays a notice if an update is available. +- The check runs asynchronously and does not block startup. Network errors are silently ignored. + #### Automatic Found Hash Merging (Download Left Only) When downloading left hashes (uncracked hashes), hate_crack automatically: @@ -706,20 +731,27 @@ uv pip install -e ".[ml]" This installs PyTorch and HuggingFace Transformers. GPU acceleration (CUDA/MPS) is auto-detected but not required. **Configuration keys:** -* `passgptModel` - HuggingFace model name (default: `javirandor/passgpt-10characters`) -* `passgptMaxCandidates` - Maximum candidates to generate (default: 1000000) -* `passgptBatchSize` - Generation batch size (default: 1024) +- `passgptModel` - HuggingFace model name (default: `javirandor/passgpt-10characters`) +- `passgptMaxCandidates` - Maximum candidates to generate (default: 1000000) +- `passgptBatchSize` - Generation batch size (default: 1024) **Supported models:** -* `javirandor/passgpt-10characters` - Trained on passwords up to 10 characters (default) -* `javirandor/passgpt-16characters` - Trained on passwords up to 16 characters -* Any compatible GPT-2 model on HuggingFace +- `javirandor/passgpt-10characters` - Trained on passwords up to 10 characters (default) +- `javirandor/passgpt-16characters` - Trained on passwords up to 16 characters +- Any compatible GPT-2 model on HuggingFace **Standalone usage:** ```bash python -m hate_crack.passgpt_generate --num 1000 --model javirandor/passgpt-10characters ``` +Available command-line options: +- `--num` - Number of candidates to generate (default: 1000000) +- `--model` - HuggingFace model name (default: javirandor/passgpt-10characters) +- `--batch-size` - Generation batch size (default: 1024) +- `--max-length` - Max token length including special tokens (default: 12) +- `--device` - Device: cuda, mps, or cpu (default: auto-detect) + #### Download Rules from Hashmob.net Downloads the latest rule files from Hashmob.net's rule repository. These rules are curated and optimized for password cracking and can be used with the Quick Crack and Loopback Attack modes. @@ -752,16 +784,19 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi ------------------------------------------------------------------- ### Version History + Version 2.0+ - Added PassGPT Attack (option 17) using GPT-2 based ML password generation - Added PassGPT configuration keys (passgptModel, passgptMaxCandidates, passgptBatchSize) - Added [ml] optional dependency group for PyTorch and Transformers - Added OMEN Attack (option 16) using statistical model-based password generation - Added OMEN configuration keys (omenTrainingList, omenMaxCandidates) - Added LLM Attack (option 15) using Ollama for AI-generated password candidates - Added Ollama configuration keys (ollamaModel, ollamaNumCtx) - Auto-versioning via setuptools-scm from git tags - CI test fixes across Python 3.9–3.14 + - Added automatic update checks on startup (check_for_updates config option) + - Added `packaging` dependency for version comparison + - Added PassGPT Attack (option 17) using GPT-2 based ML password generation + - Added PassGPT configuration keys (passgptModel, passgptMaxCandidates, passgptBatchSize) + - Added `[ml]` optional dependency group for PyTorch and Transformers + - Added OMEN Attack (option 16) using statistical model-based password generation + - Added OMEN configuration keys (omenTrainingList, omenMaxCandidates) + - Added LLM Attack (option 15) using Ollama for AI-generated password candidates + - Added Ollama configuration keys (ollamaModel, ollamaNumCtx) + - Auto-versioning via setuptools-scm from git tags + - CI test fixes across Python 3.9-3.14 Version 2.0 Modularized codebase into CLI/API/attacks modules diff --git a/hate_crack.py b/hate_crack.py index 1d8ffbf..d9fb181 100755 --- a/hate_crack.py +++ b/hate_crack.py @@ -85,7 +85,6 @@ def get_main_menu_options(): "14": _attacks.loopback_attack, "15": _attacks.ollama_attack, "16": _attacks.omen_attack, - "17": _attacks.passgpt_attack, "90": download_hashmob_rules, "91": weakpass_wordlist_menu, "92": download_hashmob_wordlists, @@ -96,6 +95,8 @@ def get_main_menu_options(): "98": show_readme, "99": quit_hc, } + if globals().get("HAS_ML_DEPS"): + options["17"] = _attacks.passgpt_attack # Only show Hashview API when configured. if globals().get("hashview_api_key"): options["94"] = hashview_api diff --git a/hate_crack/main.py b/hate_crack/main.py index 5836bc8..3bdc866 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -3480,7 +3480,6 @@ def get_main_menu_options(): "14": loopback_attack, "15": ollama_attack, "16": omen_attack, - "17": passgpt_attack, "90": download_hashmob_rules, "91": analyze_rules, "92": download_hashmob_wordlists, @@ -3491,6 +3490,8 @@ def get_main_menu_options(): "98": show_readme, "99": quit_hc, } + if HAS_ML_DEPS: + options["17"] = passgpt_attack # Only show this when Hashview API is configured (requested behavior). if hashview_api_key: options["94"] = hashview_api @@ -4138,7 +4139,8 @@ def main(): print("\t(14) Loopback Attack") print("\t(15) LLM Attack") print("\t(16) OMEN Attack") - print("\t(17) PassGPT Attack") + if HAS_ML_DEPS: + print("\t(17) PassGPT Attack") print("\n\t(90) Download rules from Hashmob.net") print("\n\t(91) Analyze Hashcat Rules") print("\t(92) Download wordlists from Hashmob.net") diff --git a/hate_crack/passgpt_generate.py b/hate_crack/passgpt_generate.py index 16c39d1..83c37e1 100644 --- a/hate_crack/passgpt_generate.py +++ b/hate_crack/passgpt_generate.py @@ -11,6 +11,9 @@ import argparse import sys +_MPS_BATCH_SIZE_CAP = 64 + + def _detect_device() -> str: import torch @@ -21,6 +24,14 @@ def _detect_device() -> str: return "cpu" +def _configure_mps() -> None: + """Set MPS memory limits before torch is imported.""" + import os + + os.environ.setdefault("PYTORCH_MPS_HIGH_WATERMARK_RATIO", "0.5") + os.environ.setdefault("PYTORCH_MPS_LOW_WATERMARK_RATIO", "0.3") + + def generate( num: int, model_name: str, @@ -28,15 +39,27 @@ def generate( max_length: int, device: str | None, ) -> None: + # If MPS is requested (or will be auto-detected), set memory limit before importing torch + if device == "mps" or device is None: + _configure_mps() + import torch - from transformers import GPT2LMHeadModel, RobertaTokenizerFast + from transformers import GPT2LMHeadModel # type: ignore[attr-defined] + from transformers import RobertaTokenizerFast # type: ignore[attr-defined] if device is None: device = _detect_device() + if device == "mps" and batch_size > _MPS_BATCH_SIZE_CAP: + print( + f"[*] Capping batch size from {batch_size} to {_MPS_BATCH_SIZE_CAP} for MPS", + file=sys.stderr, + ) + batch_size = _MPS_BATCH_SIZE_CAP + print(f"[*] Loading model {model_name} on {device}", file=sys.stderr) tokenizer = RobertaTokenizerFast.from_pretrained(model_name) - model = GPT2LMHeadModel.from_pretrained(model_name).to(device) + model = GPT2LMHeadModel.from_pretrained(model_name).to(device) # type: ignore[arg-type] model.eval() generated = 0 @@ -64,8 +87,12 @@ def generate( # Strip BOS token output = output[:, 1:] for seq in output: - decoded = tokenizer.decode(seq, skip_special_tokens=False) - password = decoded.split("")[0] + token_strs = [tokenizer.decode([t]) for t in seq] + password = "" + for t in token_strs: + if t in (tokenizer.eos_token, tokenizer.pad_token): + break + password += t.replace(" ", "") if password and password not in seen: seen.add(password) sys.stdout.write(password + "\n") diff --git a/tests/test_omen_attack.py b/tests/test_omen_attack.py index cd8ae12..84bc412 100644 --- a/tests/test_omen_attack.py +++ b/tests/test_omen_attack.py @@ -19,10 +19,16 @@ class TestHcatOmenTrain: create_bin = omen_dir / "createNG" create_bin.touch() create_bin.chmod(0o755) + model_dir = tmp_path / "model" + model_dir.mkdir() with patch.object(main_module, "hate_path", str(tmp_path)), patch.object( main_module, "hcatOmenCreateBin", "createNG" - ), patch("hate_crack.main.subprocess.Popen") as mock_popen: + ), patch( + "hate_crack.main._omen_model_dir", return_value=str(model_dir) + ), patch( + "hate_crack.main.subprocess.Popen" + ) as mock_popen: mock_proc = MagicMock() mock_proc.wait.return_value = None mock_proc.returncode = 0 @@ -35,6 +41,17 @@ class TestHcatOmenTrain: assert cmd[0] == str(create_bin) assert "--iPwdList" in cmd assert str(training_file) in cmd + # Verify explicit output paths are passed + assert "-C" in cmd + assert str(model_dir / "createConfig") in cmd + assert "-c" in cmd + assert str(model_dir / "CP") in cmd + assert "-i" in cmd + assert str(model_dir / "IP") in cmd + assert "-e" in cmd + assert str(model_dir / "EP") in cmd + assert "-l" in cmd + assert str(model_dir / "LN") in cmd def test_missing_binary(self, main_module, tmp_path, capsys): training_file = tmp_path / "passwords.txt" @@ -52,10 +69,12 @@ class TestHcatOmenTrain: omen_dir.mkdir() create_bin = omen_dir / "createNG" create_bin.touch() + model_dir = tmp_path / "model" + model_dir.mkdir() with patch.object(main_module, "hate_path", str(tmp_path)), patch.object( main_module, "hcatOmenCreateBin", "createNG" - ): + ), patch("hate_crack.main._omen_model_dir", return_value=str(model_dir)): main_module.hcatOmenTrain("/nonexistent/file.txt") captured = capsys.readouterr() assert "Training file not found" in captured.out @@ -68,6 +87,9 @@ class TestHcatOmen: enum_bin = omen_dir / "enumNG" enum_bin.touch() enum_bin.chmod(0o755) + model_dir = tmp_path / "model" + model_dir.mkdir() + (model_dir / "createConfig").write_text("# test config\n") with patch.object(main_module, "hate_path", str(tmp_path)), patch.object( main_module, "hcatOmenEnumBin", "enumNG" @@ -77,6 +99,8 @@ class TestHcatOmen: main_module, "hcatPotfilePath", "" ), patch.object( main_module, "hcatHashFile", "/tmp/hashes.txt", create=True + ), patch( + "hate_crack.main._omen_model_dir", return_value=str(model_dir) ), patch( "hate_crack.main.subprocess.Popen" ) as mock_popen: @@ -96,6 +120,10 @@ class TestHcatOmen: assert "-p" in enum_cmd assert "-m" in enum_cmd assert "500000" in enum_cmd + assert "-C" in enum_cmd + assert str(model_dir / "createConfig") in enum_cmd + # cwd should be model_dir + assert mock_popen.call_args_list[0][1]["cwd"] == str(model_dir) # Second call: hashcat hashcat_cmd = mock_popen.call_args_list[1][0][0] assert hashcat_cmd[0] == "hashcat" @@ -110,6 +138,23 @@ class TestHcatOmen: captured = capsys.readouterr() assert "enumNG binary not found" in captured.out + def test_missing_model(self, main_module, tmp_path, capsys): + omen_dir = tmp_path / "omen" + omen_dir.mkdir() + enum_bin = omen_dir / "enumNG" + enum_bin.touch() + enum_bin.chmod(0o755) + model_dir = tmp_path / "model" + model_dir.mkdir() + # No createConfig in model_dir + + with patch.object(main_module, "hate_path", str(tmp_path)), patch.object( + main_module, "hcatOmenEnumBin", "enumNG" + ), patch("hate_crack.main._omen_model_dir", return_value=str(model_dir)): + main_module.hcatOmen("1000", "/tmp/hashes.txt", 500000) + captured = capsys.readouterr() + assert "OMEN model not found" in captured.out + class TestOmenAttackHandler: def test_prompts_and_calls_hcatOmen(self): @@ -120,9 +165,13 @@ class TestOmenAttackHandler: ctx.hcatHashType = "1000" ctx.hcatHashFile = "/tmp/hashes.txt" - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", return_value="" - ): + def fake_isfile(path): + # Binaries exist, model exists + return True + + with patch("os.path.isfile", side_effect=fake_isfile), patch( + "os.path.expanduser", return_value="/fake/home" + ), patch("builtins.input", return_value=""): from hate_crack.attacks import omen_attack omen_attack(ctx) @@ -138,11 +187,12 @@ class TestOmenAttackHandler: ctx.hcatHashFile = "/tmp/hashes.txt" def fake_isfile(path): - return "IP.level" not in path + # Binaries exist, but createConfig does not + return "createConfig" not in path with patch("os.path.isfile", side_effect=fake_isfile), patch( - "builtins.input", return_value="" - ): + "os.path.expanduser", return_value="/fake/home" + ), patch("builtins.input", return_value=""): from hate_crack.attacks import omen_attack omen_attack(ctx) diff --git a/tests/test_version_check.py b/tests/test_version_check.py new file mode 100644 index 0000000..732d407 --- /dev/null +++ b/tests/test_version_check.py @@ -0,0 +1,119 @@ +"""Tests for the startup version check feature.""" + +import json +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def hc_module(): + """Load hate_crack.main with SKIP_INIT enabled.""" + import os + import importlib + + os.environ["HATE_CRACK_SKIP_INIT"] = "1" + mod = importlib.import_module("hate_crack.main") + return mod + + +class TestCheckForUpdates: + """Tests for check_for_updates().""" + + def test_newer_version_prints_update_notice(self, hc_module, capsys): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": "v99.0.0"} + mock_resp.raise_for_status = MagicMock() + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ): + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + output = capsys.readouterr().out + assert "Update available: 99.0.0" in output + assert "github.com/trustedsec/hate_crack/releases" in output + + def test_same_version_prints_nothing(self, hc_module, capsys): + from hate_crack import __version__ + + local_base = __version__.split("+")[0] + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": f"v{local_base}"} + mock_resp.raise_for_status = MagicMock() + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ): + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + output = capsys.readouterr().out + assert "Update available" not in output + + def test_older_version_prints_nothing(self, hc_module, capsys): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": "v0.0.1"} + mock_resp.raise_for_status = MagicMock() + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ): + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + output = capsys.readouterr().out + assert "Update available" not in output + + def test_network_error_silently_handled(self, hc_module, capsys): + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ): + mock_requests.get.side_effect = ConnectionError("no network") + hc_module.check_for_updates() + + output = capsys.readouterr().out + assert "Update available" not in output + assert "Error" not in output + + def test_requests_unavailable_skips_check(self, hc_module, capsys): + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", False + ): + hc_module.check_for_updates() + mock_requests.get.assert_not_called() + + def test_config_disabled_skips_check(self, hc_module): + """Verify that check_for_updates_enabled=False prevents the call in main().""" + # The config flag is checked in main() before calling check_for_updates(). + # We verify the flag loads correctly from config. + assert hasattr(hc_module, "check_for_updates_enabled") + + def test_tag_without_v_prefix(self, hc_module, capsys): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": "99.0.0"} + mock_resp.raise_for_status = MagicMock() + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ): + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + output = capsys.readouterr().out + assert "Update available: 99.0.0" in output + + def test_empty_tag_name_handled(self, hc_module, capsys): + mock_resp = MagicMock() + mock_resp.json.return_value = {"tag_name": ""} + mock_resp.raise_for_status = MagicMock() + + with patch.object(hc_module, "requests") as mock_requests, patch.object( + hc_module, "REQUESTS_AVAILABLE", True + ): + mock_requests.get.return_value = mock_resp + hc_module.check_for_updates() + + output = capsys.readouterr().out + assert "Update available" not in output