mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 22:51:14 -07:00
* fix: use sys.executable for PCFG/PRINCE-LING subprocesses
pcfg_guesser.py and prince_ling.py were launched via a bare "python3"
resolved from PATH, so they ran under whatever interpreter happened to
be first on PATH instead of the pinned 3.13 hate_crack itself runs
under. sys.executable always resolves to that interpreter, which also
makes the shutil.which("python3") presence check unnecessary.
Fixes #149
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: match pcfgRuleset default casing to on-disk Rules/Default
pcfgRuleset defaulted to "DEFAULT" but pcfg_cracker ships Rules/Default
(capitalized, not all-caps). Case-insensitive filesystems (macOS)
masked the mismatch; on case-sensitive filesystems (most Linux) both
PCFG-based attacks failed out of the box.
Fixes #148
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: keep PCFG/PRINCE-LING child stdin open to avoid silent truncation
hcatPCFG launched pcfg_guesser.py with no stdin=, so it inherited
hate_crack's own stdin. Whenever that stdin isn't a TTY (cron, CI,
detached runs, piped input), pcfg_guesser's keypress-listener thread
hit EOFError on its first input() call and the guesser shut itself
down after tens of thousands of candidates, silently ignoring
--limit/pcfgMaxCandidates. Passing stdin=subprocess.PIPE keeps the fd
open without writing to it, so input() blocks instead of raising.
prince_ling.py comes from the same upstream project and shares the
keypress thread, so hcatPrinceLing gets the same treatment.
Fixes #146
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* style: apply ruff format to hcatPCFG Popen call
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: catch OSError loading config.json.example defaults
The config.json.example defaults load only caught JSONDecodeError, so
a missing or unreadable file (e.g. a dangling symlink surviving a
git-archive tarball, docker COPY, or partial /opt/hate_crack install)
escaped as an uncaught FileNotFoundError instead of the "package
installation issue" message already written for exactly this case.
Extracted the load into _load_config_defaults() so the failure paths
are unit-testable, and added a dangling-symlink-specific message.
Fixes #155
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* style: apply ruff format to _load_config_defaults
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: replace brittle config.json.example test guards
test_packaged_example_is_symlink_to_root asserted symlink-ness, an
implementation detail that setuptools dereferences in every built
wheel/sdist — the test only ever passed in the source tree.
test_packaged_and_root_examples_have_identical_content compared the
same inode to itself there, making it a tautology. Neither would catch
real drift in a distributed artifact. Replaced both with an explicit
expected-key-set assertion (the substantive invariant #150 needed) and
a content-parity check that's only exercised outside the source tree.
Fixes #154
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: share config.json.example defaults between api.py and main.py
api.py's get_hcat_wordlists_dir/get_rules_dir/get_hcat_tuning_args/
get_hcat_potfile_path read config.json directly and fell back to their
own hardcoded, cwd-relative defaults for any key absent from disk.
main.py merges config.json.example into config_parser in memory (since
af46f59 stopped backfilling it to disk), so any key missing from a
user's config.json now resolves differently depending on which module
resolved it — e.g. rules_directory: <hate_path>/hashcat/rules in
main.py vs <cwd>/rules in api.py, silently creating an empty directory
under whatever directory the user launched from.
Added _load_config_defaults()/_load_merged_config() in api.py so both
modules perform the same config.json.example-then-config.json merge.
Fixes #153
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
* fix: revert inert hcatPrinceLing stdin=PIPE change
prince_ling.py never imports lib_guesser.cracking_session (the only
file with input()/a keypress thread) and never reads stdin, so there
was nothing for stdin=PIPE to fix there. Worse, subprocess.run(...,
stdin=subprocess.PIPE) with no input= argument closes the pipe
immediately via communicate(None), so the change was inert regardless.
The hcatPCFG side of #146 (Popen with stdin=PIPE, which correctly
keeps the write end open) is unaffected and remains the actual fix.
* fix: resolve pcfgRuleset case-insensitively against Rules/ on disk
Users with a config.json predating this stack's default-casing fix
have "DEFAULT" backfilled to disk (hate_crack used to write missing
keys to disk; see af46f59). For them, the new "Default" default in
config.json.example is never consulted, so Rules/DEFAULT still
doesn't resolve on case-sensitive filesystems and both PCFG attacks
stay broken. Resolve the configured ruleset name case-insensitively
against whatever casing actually exists under pcfg_cracker/Rules/, so
both a stale "DEFAULT" on disk and a user typo like "default" resolve
to the real Rules/Default directory.
* fix: resolve relative hcatPotfilePath against hate_path, not config dir
get_hcat_potfile_path resolved a relative hcatPotfilePath against the
config.json's own directory, while main.py resolves it against
hate_path unconditionally. These only agreed when config.json lived
at hate_path — they diverged for the ~/.hate_crack candidate root,
which is a first-class supported config location. This was the exact
divergence class #153 set out to eliminate, left inside the function
meant to fix it.
* fix: use resolved ruleset basename for prince_ling argv and cache path
hcatPrinceLing's ruleset existence check was fixed to resolve
pcfgRuleset case-insensitively against Rules/ on disk, but the
subprocess argv passed to prince_ling.py and the cache filename both
still used the raw, unresolved value. prince_ling.py does its own
(case-sensitive) Rules/ lookup internally, so a legacy "DEFAULT"
config would pass hate_crack's precheck and then fail inside
prince_ling.py itself with a confusing "generation failed" message.
Use the resolved basename in both places, matching what hcatPCFG
already does for its --rule argument.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
285 lines
9.3 KiB
Python
285 lines
9.3 KiB
Python
import json
|
|
import logging
|
|
import os
|
|
import importlib
|
|
|
|
|
|
from hate_crack import api
|
|
from hate_crack import cli
|
|
from hate_crack import formatting
|
|
|
|
|
|
def test_orig_cwd_uses_env_var(monkeypatch, tmp_path):
|
|
"""orig_cwd() returns HATE_CRACK_ORIG_CWD when set."""
|
|
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(tmp_path))
|
|
assert cli.orig_cwd() == str(tmp_path)
|
|
|
|
|
|
def test_orig_cwd_falls_back_to_getcwd(monkeypatch):
|
|
"""orig_cwd() falls back to os.getcwd() when env var is unset."""
|
|
monkeypatch.delenv("HATE_CRACK_ORIG_CWD", raising=False)
|
|
assert cli.orig_cwd() == os.getcwd()
|
|
|
|
|
|
def test_resolve_path_none_and_expand():
|
|
assert cli.resolve_path("") is None
|
|
resolved = cli.resolve_path("~")
|
|
assert resolved is not None
|
|
assert os.path.isabs(resolved)
|
|
|
|
|
|
def test_resolve_path_uses_orig_cwd_for_relative_paths(monkeypatch, tmp_path):
|
|
"""When HATE_CRACK_ORIG_CWD is set, relative paths resolve against it."""
|
|
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(tmp_path))
|
|
result = cli.resolve_path("hashes.txt")
|
|
assert result == os.path.join(str(tmp_path), "hashes.txt")
|
|
|
|
|
|
def test_resolve_path_ignores_orig_cwd_for_absolute_paths(monkeypatch, tmp_path):
|
|
"""Absolute paths are returned as-is regardless of HATE_CRACK_ORIG_CWD."""
|
|
monkeypatch.setenv("HATE_CRACK_ORIG_CWD", str(tmp_path))
|
|
result = cli.resolve_path("/absolute/path/hashes.txt")
|
|
assert result == "/absolute/path/hashes.txt"
|
|
|
|
|
|
def test_resolve_path_without_orig_cwd_uses_abspath(monkeypatch):
|
|
"""Without HATE_CRACK_ORIG_CWD, falls back to os.path.abspath."""
|
|
monkeypatch.delenv("HATE_CRACK_ORIG_CWD", raising=False)
|
|
result = cli.resolve_path("hashes.txt")
|
|
assert result == os.path.abspath("hashes.txt")
|
|
|
|
|
|
def test_setup_logging_adds_single_streamhandler(tmp_path):
|
|
logger = logging.getLogger("hate_crack_test")
|
|
logger.handlers.clear()
|
|
cli.setup_logging(logger, str(tmp_path), debug_mode=True)
|
|
cli.setup_logging(logger, str(tmp_path), debug_mode=True)
|
|
|
|
stream_handlers = [
|
|
h
|
|
for h in logger.handlers
|
|
if isinstance(h, logging.StreamHandler)
|
|
and not isinstance(h, logging.FileHandler)
|
|
]
|
|
assert len(stream_handlers) == 1
|
|
file_handlers = [h for h in logger.handlers if isinstance(h, logging.FileHandler)]
|
|
assert file_handlers == []
|
|
|
|
logger.handlers.clear()
|
|
|
|
|
|
def test_print_multicolumn_list_truncates(capsys, monkeypatch):
|
|
# Avoid patching os.get_terminal_size (pytest uses it internally).
|
|
monkeypatch.setattr(formatting, "_terminal_width", lambda default=120: 10)
|
|
formatting.print_multicolumn_list(
|
|
"Title",
|
|
["abcdefghijk"],
|
|
min_col_width=1,
|
|
max_col_width=10,
|
|
)
|
|
captured = capsys.readouterr()
|
|
assert "..." in captured.out
|
|
|
|
|
|
def test_print_multicolumn_list_empty_entries(capsys):
|
|
formatting.print_multicolumn_list("Empty", [])
|
|
captured = capsys.readouterr()
|
|
assert "(none)" in captured.out
|
|
|
|
|
|
def test_get_hcat_wordlists_dir_from_config(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text('{"hcatWordlists": "wordlists"}')
|
|
|
|
monkeypatch.setattr(api, "_resolve_config_path", lambda: str(config_path))
|
|
monkeypatch.setattr(api, "_get_hate_path", lambda: str(tmp_path))
|
|
result = api.get_hcat_wordlists_dir()
|
|
|
|
assert result == str(tmp_path / "wordlists")
|
|
assert os.path.isdir(result)
|
|
|
|
|
|
def test_get_hcat_wordlists_dir_no_config_uses_example_default(tmp_path, monkeypatch):
|
|
"""Regression test for #153: with no config.json on disk, api.py must
|
|
resolve the same default main.py does (config.json.example's
|
|
hcatWordlists, relative to hate_path) rather than a hardcoded
|
|
cwd-relative fallback that only main.py knew to avoid.
|
|
"""
|
|
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
|
|
monkeypatch.setattr(api, "_get_hate_path", lambda: str(tmp_path))
|
|
|
|
result = api.get_hcat_wordlists_dir()
|
|
|
|
assert result == str(tmp_path / "wordlists")
|
|
assert os.path.isdir(result)
|
|
|
|
|
|
def test_get_hcat_wordlists_dir_true_fallback_when_no_example(tmp_path, monkeypatch):
|
|
"""Last-resort cwd fallback still applies if config.json.example itself
|
|
can't be found or read (e.g. hate_crack.main's equivalent loader exits
|
|
at import time on an unreadable example; api.py's degrades gracefully
|
|
instead)."""
|
|
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
|
|
monkeypatch.setattr(api, "_load_config_defaults", lambda: {})
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
result = api.get_hcat_wordlists_dir()
|
|
|
|
assert result == str(tmp_path / "wordlists")
|
|
assert os.path.isdir(result)
|
|
|
|
|
|
def test_get_rules_dir_from_config(tmp_path, monkeypatch):
|
|
config_path = tmp_path / "config.json"
|
|
config_path.write_text('{"rules_directory": "rules"}')
|
|
|
|
monkeypatch.setattr(api, "_resolve_config_path", lambda: str(config_path))
|
|
monkeypatch.setattr(api, "_get_hate_path", lambda: str(tmp_path))
|
|
result = api.get_rules_dir()
|
|
|
|
assert result == str(tmp_path / "rules")
|
|
assert os.path.isdir(result)
|
|
|
|
|
|
def test_get_rules_dir_no_config_uses_example_default(tmp_path, monkeypatch):
|
|
"""Regression test for #153: api.py's rules_directory default must match
|
|
main.py's ('./hashcat/rules' from config.json.example), not the
|
|
hardcoded '<cwd>/rules' this helper used before the fix.
|
|
"""
|
|
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
|
|
monkeypatch.setattr(api, "_get_hate_path", lambda: str(tmp_path))
|
|
|
|
result = api.get_rules_dir()
|
|
|
|
assert result == str(tmp_path / "hashcat" / "rules")
|
|
assert os.path.isdir(result)
|
|
|
|
|
|
def test_get_hcat_tuning_args_merges_example_default(monkeypatch):
|
|
monkeypatch.setattr(api, "_resolve_config_path", lambda: None)
|
|
|
|
result = api.get_hcat_tuning_args()
|
|
|
|
# config.json.example ships hcatTuning: "" — merged default, empty split
|
|
assert result == []
|
|
|
|
|
|
def test_get_hcat_tuning_args_config_overrides_default(tmp_path, monkeypatch):
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text(json.dumps({"hcatTuning": "-O -w 3"}))
|
|
monkeypatch.setattr(api, "_resolve_config_path", lambda: str(config_file))
|
|
|
|
result = api.get_hcat_tuning_args()
|
|
|
|
assert result == ["-O", "-w", "3"]
|
|
|
|
|
|
def test_cleanup_torrent_files_removes_only_torrents(tmp_path):
|
|
torrent = tmp_path / "a.torrent"
|
|
keep = tmp_path / "b.txt"
|
|
torrent.write_text("data")
|
|
keep.write_text("data")
|
|
|
|
api.cleanup_torrent_files(directory=str(tmp_path))
|
|
|
|
assert not torrent.exists()
|
|
assert keep.exists()
|
|
|
|
|
|
def test_cleanup_torrent_files_missing_dir(capsys, tmp_path):
|
|
missing = tmp_path / "missing"
|
|
api.cleanup_torrent_files(directory=str(missing))
|
|
captured = capsys.readouterr()
|
|
assert "Failed to cleanup torrent files" in captured.out
|
|
|
|
|
|
def test_register_torrent_cleanup_idempotent(monkeypatch):
|
|
calls = []
|
|
|
|
def fake_register(fn):
|
|
calls.append(fn)
|
|
|
|
monkeypatch.setattr(api, "_TORRENT_CLEANUP_REGISTERED", False)
|
|
monkeypatch.setattr("atexit.register", fake_register)
|
|
|
|
api.register_torrent_cleanup()
|
|
api.register_torrent_cleanup()
|
|
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_line_count_and_write_helpers(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HATE_CRACK_SKIP_INIT", "1")
|
|
from hate_crack import main as main_module
|
|
|
|
importlib.reload(main_module)
|
|
|
|
input_path = tmp_path / "input.txt"
|
|
input_path.write_text("a:b:c\nno-delim\n1:2:3\n")
|
|
out_delimited = tmp_path / "out_delimited.txt"
|
|
out_unique = tmp_path / "out_unique.txt"
|
|
|
|
assert main_module.lineCount(str(input_path)) == 3
|
|
assert main_module.lineCount(str(tmp_path / "missing.txt")) == 0
|
|
|
|
assert (
|
|
main_module._write_delimited_field(str(input_path), str(out_delimited), 2)
|
|
is True
|
|
)
|
|
assert out_delimited.read_text().splitlines() == ["b", "2"]
|
|
assert (
|
|
main_module._write_delimited_field(
|
|
str(tmp_path / "missing.txt"), str(out_delimited), 2
|
|
)
|
|
is False
|
|
)
|
|
|
|
class FakePopen:
|
|
def __init__(self, args, stdin=None, stdout=None, text=None, **_kwargs):
|
|
self.stdin = FakeStdin(self)
|
|
self._stdout = stdout
|
|
self._data = None
|
|
|
|
def wait(self):
|
|
for line in sorted(set(self._data)):
|
|
self._stdout.write(line + "\n")
|
|
return 0
|
|
|
|
class FakeStdin:
|
|
def __init__(self, popen):
|
|
self._popen = popen
|
|
self._lines = []
|
|
|
|
def write(self, data):
|
|
self._lines.append(data.rstrip("\n"))
|
|
|
|
def close(self):
|
|
self._popen._data = self._lines
|
|
|
|
monkeypatch.setattr(main_module.subprocess, "Popen", FakePopen)
|
|
|
|
assert (
|
|
main_module._write_field_sorted_unique(str(input_path), str(out_unique), 2)
|
|
is True
|
|
)
|
|
assert out_unique.read_text().splitlines() == ["2", "b"]
|
|
|
|
|
|
def test_get_customer_hashfiles_with_hashtype_filters(monkeypatch):
|
|
hv = api.HashviewAPI("https://example", "key")
|
|
monkeypatch.setattr(
|
|
hv,
|
|
"get_customer_hashfiles",
|
|
lambda customer_id, hash_type=None: [
|
|
{"customer_id": customer_id, "hashtype": "1000"},
|
|
{"customer_id": customer_id, "hash_type": "0"},
|
|
],
|
|
)
|
|
|
|
matches = hv.get_customer_hashfiles_with_hashtype(1, target_hashtype="1000")
|
|
assert len(matches) == 1
|
|
assert matches[0]["hashtype"] == "1000"
|
|
|
|
none = hv.get_customer_hashfiles_with_hashtype(1, target_hashtype="999")
|
|
assert none == []
|