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 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-02-18 09:32:40 -05:00
co-authored by Claude Opus 4.6
parent 39970b41c4
commit f0b512a079
6 changed files with 264 additions and 30 deletions
+50 -15
View File
@@ -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.93.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
+2 -1
View File
@@ -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
+4 -2
View File
@@ -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")
+31 -4
View File
@@ -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("</s>")[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")
+58 -8
View File
@@ -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)
+119
View File
@@ -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