mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 14:47:22 -07:00
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:
co-authored by
Claude Opus 4.6
parent
39970b41c4
commit
f0b512a079
@@ -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
|
||||
|
||||
+2
-1
@@ -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
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user