mirror of
https://github.com/trustedsec/hate_crack.git
synced 2026-07-28 22:51:14 -07:00
fix: overhaul Hashview download flow and fix hashcat --show stderr pollution
- Merge download_left and download_found into single "Download Hashes" menu option - Append found hash:clear pairs to potfile instead of running broken hashcat re-crack - Append found hashes to left file so hashcat --show returns full results - Clean up found_ temp files after merge - Split found file on first colon (not last) to handle passwords containing colons - Filter hashcat parse errors from --show stdout in _run_hashcat_show - Add get_hcat_potfile_path() helper to api.py for potfile resolution - Remove obsolete download_found_hashes API method and CLI subcommand - Fix ollama tests to match current 4-arg hcatOllama signature and rule loop Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
ca1d71da6c
commit
f9db54e84c
+53
-267
@@ -117,6 +117,29 @@ def get_hcat_tuning_args():
|
||||
return []
|
||||
|
||||
|
||||
def get_hcat_potfile_path():
|
||||
"""Return the resolved potfile path from config, or the default."""
|
||||
config_path = _resolve_config_path()
|
||||
if config_path:
|
||||
try:
|
||||
with open(config_path) as f:
|
||||
config = json.load(f)
|
||||
raw = (config.get("hcatPotfilePath") or "").strip()
|
||||
if raw:
|
||||
return os.path.expanduser(raw)
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.expanduser("~/.hashcat/hashcat.potfile")
|
||||
|
||||
|
||||
def get_hcat_potfile_args():
|
||||
"""Return potfile args list for hashcat, e.g. ['--potfile-path=/path']."""
|
||||
pot = get_hcat_potfile_path()
|
||||
if pot:
|
||||
return [f"--potfile-path={pot}"]
|
||||
return []
|
||||
|
||||
|
||||
def cleanup_torrent_files(directory=None):
|
||||
"""Remove stray .torrent files from the wordlists directory on graceful exit."""
|
||||
if directory is None:
|
||||
@@ -805,7 +828,6 @@ class HashviewAPI:
|
||||
self, customer_id, hashfile_id, output_file=None, hash_type=None
|
||||
):
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/left"
|
||||
resp = self.session.get(url, headers=self._auth_headers(), stream=True)
|
||||
@@ -882,7 +904,7 @@ class HashviewAPI:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
parts = line.rsplit(":", 1) # Split on last colon
|
||||
parts = line.split(":", 1) # Split on first colon
|
||||
if len(parts) == 2:
|
||||
hash_part, clear_part = parts
|
||||
hf.write(hash_part + "\n")
|
||||
@@ -894,277 +916,41 @@ class HashviewAPI:
|
||||
f"Split found file into {hashes_count} hashes and {clears_count} clears"
|
||||
)
|
||||
|
||||
# Run hashcat to combine them
|
||||
combined_file = output_abs + ".out"
|
||||
try:
|
||||
# Execute hashcat: hashcat <tuning> -m hash_type found_hashes found_clears --outfile output.out --outfile-format=1,2
|
||||
tuning_args = get_hcat_tuning_args()
|
||||
# Append found hashes to the left file
|
||||
with open(output_abs, "a", encoding="utf-8") as lf:
|
||||
with open(found_hashes_file, "r", encoding="utf-8", errors="ignore") as fhf:
|
||||
for line in fhf:
|
||||
line = line.strip()
|
||||
if line:
|
||||
lf.write(line + "\n")
|
||||
print(f"✓ Appended {hashes_count} found hashes to {output_abs}")
|
||||
|
||||
# Create temporary outfile for hashcat
|
||||
temp_outfile = output_abs + ".tmp"
|
||||
|
||||
if self.debug:
|
||||
print(
|
||||
f"[DEBUG] download_left_hashes: hash_type={hash_type}, type={type(hash_type)}"
|
||||
)
|
||||
|
||||
# Build command with hash type if provided
|
||||
cmd = ["hashcat", *tuning_args]
|
||||
if hash_type:
|
||||
cmd.extend(["-m", str(hash_type)])
|
||||
cmd.extend(
|
||||
[
|
||||
found_hashes_file,
|
||||
found_clears_file,
|
||||
"--outfile",
|
||||
temp_outfile,
|
||||
"--outfile-format=1,2",
|
||||
]
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
print(f"[DEBUG] Running command: {' '.join(cmd)}")
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: hashcat exited with code {result.returncode}")
|
||||
if result.stderr:
|
||||
print(f" stderr: {result.stderr}")
|
||||
|
||||
# Append the output to the combined file
|
||||
if os.path.exists(temp_outfile):
|
||||
with open(
|
||||
temp_outfile, "r", encoding="utf-8", errors="ignore"
|
||||
) as tmp_f:
|
||||
with open(combined_file, "a", encoding="utf-8") as out_f:
|
||||
out_f.write(tmp_f.read())
|
||||
|
||||
# Count lines appended
|
||||
with open(
|
||||
combined_file, "r", encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
combined_count = len(f.readlines())
|
||||
print(
|
||||
f"✓ Appended cracked hashes to {combined_file} (total lines: {combined_count})"
|
||||
)
|
||||
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.remove(temp_outfile)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print("Note: No cracked hashes found")
|
||||
|
||||
except FileNotFoundError:
|
||||
print("✗ Error: hashcat not found in PATH")
|
||||
except subprocess.TimeoutExpired:
|
||||
print("✗ Error: hashcat execution timed out")
|
||||
except Exception as e:
|
||||
print(f"✗ Error running hashcat: {e}")
|
||||
|
||||
# Clean up temporary files (keep when debug is enabled)
|
||||
if not self.debug:
|
||||
files_to_delete = [found_file, found_hashes_file, found_clears_file]
|
||||
for temp_file in files_to_delete:
|
||||
try:
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
print(f"Deleted {temp_file}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not delete {temp_file}: {e}")
|
||||
# Append found hash:clear pairs to the potfile
|
||||
potfile_path = get_hcat_potfile_path()
|
||||
if potfile_path:
|
||||
appended = 0
|
||||
with open(potfile_path, "a", encoding="utf-8") as pf:
|
||||
with open(found_file, "r", encoding="utf-8", errors="ignore") as ff:
|
||||
for line in ff:
|
||||
line = line.strip()
|
||||
if line and ":" in line:
|
||||
pf.write(line + "\n")
|
||||
appended += 1
|
||||
combined_count = appended
|
||||
print(f"✓ Appended {appended} found hashes to potfile: {potfile_path}")
|
||||
else:
|
||||
print("Debug enabled: keeping found and split files")
|
||||
print("Warning: No potfile path configured, skipping potfile update")
|
||||
|
||||
# Clean up the two found_ files
|
||||
for f_path in (found_file, found_hashes_file, found_clears_file):
|
||||
try:
|
||||
os.remove(f_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
# If there's any error downloading found file, just skip it
|
||||
print(f"Note: Could not download found hashes: {e}")
|
||||
# Clean up found file if it was partially written
|
||||
if not self.debug:
|
||||
try:
|
||||
if os.path.exists(found_file):
|
||||
os.remove(found_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"output_file": output_file,
|
||||
"size": downloaded,
|
||||
"combined_count": combined_count,
|
||||
"combined_file": combined_file,
|
||||
}
|
||||
|
||||
def download_found_hashes(
|
||||
self, customer_id, hashfile_id, output_file=None, hash_type=None
|
||||
):
|
||||
import sys
|
||||
import subprocess
|
||||
|
||||
url = f"{self.base_url}/v1/hashfiles/{hashfile_id}/found"
|
||||
resp = self.session.get(url, headers=self._auth_headers(), stream=True)
|
||||
resp.raise_for_status()
|
||||
if output_file is None:
|
||||
output_file = f"found_{customer_id}_{hashfile_id}.txt"
|
||||
|
||||
# Convert to absolute path to ensure file is preserved if CWD changes
|
||||
output_file = os.path.abspath(output_file)
|
||||
|
||||
total = int(resp.headers.get("content-length", 0))
|
||||
downloaded = 0
|
||||
chunk_size = 8192
|
||||
with open(output_file, "wb") as f:
|
||||
for chunk in resp.iter_content(chunk_size=chunk_size):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded += len(chunk)
|
||||
if total > 0:
|
||||
done = int(50 * downloaded / total)
|
||||
bar = "[" + "=" * done + " " * (50 - done) + "]"
|
||||
percent = 100 * downloaded / total
|
||||
sys.stdout.write(
|
||||
f"\rDownloading: {bar} {percent:5.1f}% ({downloaded}/{total} bytes)"
|
||||
)
|
||||
sys.stdout.flush()
|
||||
if total > 0:
|
||||
sys.stdout.write("\n")
|
||||
# If content-length is not provided, just print size at end
|
||||
if total == 0:
|
||||
print(f"Downloaded {downloaded} bytes.")
|
||||
|
||||
# Split found file into hashes and clears
|
||||
output_dir = os.path.dirname(os.path.abspath(output_file)) or os.getcwd()
|
||||
found_hashes_file = os.path.join(
|
||||
output_dir, f"found_hashes_{customer_id}_{hashfile_id}.txt"
|
||||
)
|
||||
found_clears_file = os.path.join(
|
||||
output_dir, f"found_clears_{customer_id}_{hashfile_id}.txt"
|
||||
)
|
||||
|
||||
hashes_count = 0
|
||||
clears_count = 0
|
||||
combined_count = 0
|
||||
combined_file = None
|
||||
|
||||
try:
|
||||
with (
|
||||
open(found_hashes_file, "w", encoding="utf-8") as hf,
|
||||
open(found_clears_file, "w", encoding="utf-8") as cf,
|
||||
):
|
||||
with open(output_file, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
parts = line.rsplit(":", 1) # Split on last colon
|
||||
if len(parts) == 2:
|
||||
hash_part, clear_part = parts
|
||||
hf.write(hash_part + "\n")
|
||||
cf.write(clear_part + "\n")
|
||||
hashes_count += 1
|
||||
clears_count += 1
|
||||
|
||||
print(
|
||||
f"✓ Split found file into {hashes_count} hashes and {clears_count} clears"
|
||||
)
|
||||
|
||||
# Run hashcat to combine them into an output file
|
||||
combined_file = output_file + ".out"
|
||||
try:
|
||||
tuning_args = get_hcat_tuning_args()
|
||||
|
||||
# Create temporary outfile for hashcat
|
||||
temp_outfile = output_file + ".tmp"
|
||||
|
||||
if self.debug:
|
||||
print(
|
||||
f"[DEBUG] download_found_hashes: hash_type={hash_type}, type={type(hash_type)}"
|
||||
)
|
||||
|
||||
# Build command with hash type if provided
|
||||
cmd = ["hashcat", *tuning_args]
|
||||
if hash_type:
|
||||
cmd.extend(["-m", str(hash_type)])
|
||||
cmd.extend(
|
||||
[
|
||||
found_hashes_file,
|
||||
found_clears_file,
|
||||
"--outfile",
|
||||
temp_outfile,
|
||||
"--outfile-format=1,2",
|
||||
]
|
||||
)
|
||||
|
||||
if self.debug:
|
||||
print(f"[DEBUG] Running command: {' '.join(cmd)}")
|
||||
|
||||
print(f"Running: {' '.join(cmd)}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: hashcat exited with code {result.returncode}")
|
||||
if result.stderr:
|
||||
print(f" stderr: {result.stderr}")
|
||||
|
||||
# Write the output file
|
||||
if os.path.exists(temp_outfile):
|
||||
with open(
|
||||
temp_outfile, "r", encoding="utf-8", errors="ignore"
|
||||
) as tmp_f:
|
||||
with open(combined_file, "w", encoding="utf-8") as out_f:
|
||||
out_f.write(tmp_f.read())
|
||||
|
||||
# Count lines in output
|
||||
with open(
|
||||
combined_file, "r", encoding="utf-8", errors="ignore"
|
||||
) as f:
|
||||
combined_count = len(f.readlines())
|
||||
print(f"✓ Created {combined_file} (total lines: {combined_count})")
|
||||
|
||||
# Clean up temp file
|
||||
try:
|
||||
os.remove(temp_outfile)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
print("Note: No cracked hashes generated")
|
||||
|
||||
except FileNotFoundError:
|
||||
print("✗ Error: hashcat not found in PATH")
|
||||
except subprocess.TimeoutExpired:
|
||||
print("✗ Error: hashcat execution timed out")
|
||||
except Exception as e:
|
||||
print(f"✗ Error running hashcat: {e}")
|
||||
|
||||
# Clean up temporary files (keep when debug is enabled)
|
||||
if not self.debug:
|
||||
files_to_delete = [found_hashes_file, found_clears_file]
|
||||
for temp_file in files_to_delete:
|
||||
try:
|
||||
if os.path.exists(temp_file):
|
||||
os.remove(temp_file)
|
||||
except Exception as e:
|
||||
if self.debug:
|
||||
print(f"Warning: Could not delete {temp_file}: {e}")
|
||||
else:
|
||||
print("Debug enabled: keeping split files")
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error splitting found file: {e}")
|
||||
|
||||
return {
|
||||
"output_file": output_file,
|
||||
|
||||
+87
-296
@@ -9,7 +9,6 @@
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import lzma
|
||||
import shutil
|
||||
import logging
|
||||
import binascii
|
||||
@@ -454,15 +453,7 @@ except KeyError as e:
|
||||
default_config.get("hcatDebugLogPath", "./hashcat_debug")
|
||||
)
|
||||
|
||||
try:
|
||||
ollamaUrl = config_parser["ollamaUrl"]
|
||||
except KeyError as e:
|
||||
print(
|
||||
"{0} is not defined in config.json using defaults from config.json.example".format(
|
||||
e
|
||||
)
|
||||
)
|
||||
ollamaUrl = default_config.get("ollamaUrl", "http://localhost:11434")
|
||||
ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434")
|
||||
try:
|
||||
ollamaModel = config_parser["ollamaModel"]
|
||||
except KeyError as e:
|
||||
@@ -473,23 +464,23 @@ except KeyError as e:
|
||||
)
|
||||
ollamaModel = default_config.get("ollamaModel", "llama3.2")
|
||||
try:
|
||||
markovCandidateCount = int(config_parser["markovCandidateCount"])
|
||||
ollamaCandidateCount = int(config_parser["ollamaCandidateCount"])
|
||||
except KeyError as e:
|
||||
print(
|
||||
"{0} is not defined in config.json using defaults from config.json.example".format(
|
||||
e
|
||||
)
|
||||
)
|
||||
markovCandidateCount = int(default_config.get("markovCandidateCount", 5000))
|
||||
ollamaCandidateCount = int(default_config.get("ollamaCandidateCount", 5000))
|
||||
try:
|
||||
markovWordlist = config_parser["markovWordlist"]
|
||||
ollamaWordlist = config_parser["ollamaWordlist"]
|
||||
except KeyError as e:
|
||||
print(
|
||||
"{0} is not defined in config.json using defaults from config.json.example".format(
|
||||
e
|
||||
)
|
||||
)
|
||||
markovWordlist = default_config.get("markovWordlist", "rockyou.txt")
|
||||
ollamaWordlist = default_config.get("ollamaWordlist", "rockyou.txt")
|
||||
|
||||
hcatExpanderBin = "expander.bin"
|
||||
hcatCombinatorBin = "combinator.bin"
|
||||
@@ -629,7 +620,7 @@ hcatGoodMeasureBaseList = _normalize_wordlist_setting(
|
||||
hcatGoodMeasureBaseList, wordlists_dir
|
||||
)
|
||||
hcatPrinceBaseList = _normalize_wordlist_setting(hcatPrinceBaseList, wordlists_dir)
|
||||
markovWordlist = _normalize_wordlist_setting(markovWordlist, wordlists_dir)
|
||||
ollamaWordlist = _normalize_wordlist_setting(ollamaWordlist, wordlists_dir)
|
||||
|
||||
if not SKIP_INIT:
|
||||
# Verify hashcat binary is available
|
||||
@@ -695,7 +686,7 @@ if not SKIP_INIT:
|
||||
except SystemExit:
|
||||
print("PRINCE attacks will not be available.")
|
||||
|
||||
# Verify hcstat2gen binary (optional, for Markov attacks)
|
||||
# Verify hcstat2gen binary (optional, for LLM attacks)
|
||||
# Note: hcstat2gen is part of hashcat-utils, already in hate_crack repo
|
||||
hcstat2gen_path = (
|
||||
hate_path + "/hashcat-utils/bin/" + hcatHcstat2genBin
|
||||
@@ -707,7 +698,7 @@ if not SKIP_INIT:
|
||||
name="hcstat2gen",
|
||||
)
|
||||
except SystemExit:
|
||||
print("LLM Markov attacks will not be available.")
|
||||
print("LLM attacks will not be available.")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Module initialization error: {e}")
|
||||
@@ -938,20 +929,25 @@ def _write_field_sorted_unique(input_path, output_path, field_index, delimiter="
|
||||
|
||||
|
||||
def _run_hashcat_show(hash_type, hash_file, output_path):
|
||||
result = subprocess.run(
|
||||
[
|
||||
hcatBin,
|
||||
"--show",
|
||||
# Use hashcat's built-in potfile unless configured otherwise.
|
||||
*([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []),
|
||||
"-m",
|
||||
str(hash_type),
|
||||
hash_file,
|
||||
],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
check=False,
|
||||
)
|
||||
with open(output_path, "w") as out:
|
||||
subprocess.run(
|
||||
[
|
||||
hcatBin,
|
||||
"--show",
|
||||
# Use hashcat's built-in potfile unless configured otherwise.
|
||||
*([f"--potfile-path={hcatPotfilePath}"] if hcatPotfilePath else []),
|
||||
"-m",
|
||||
str(hash_type),
|
||||
hash_file,
|
||||
],
|
||||
stdout=out,
|
||||
check=False,
|
||||
)
|
||||
for line in result.stdout.decode("utf-8", errors="ignore").splitlines():
|
||||
# hashcat --show prints parse errors to stdout; skip non-result lines
|
||||
if ":" in line and not line.startswith(("Hash parsing error", "* ")):
|
||||
out.write(line + "\n")
|
||||
|
||||
|
||||
# Brute Force Attack
|
||||
@@ -1539,22 +1535,10 @@ def _pull_ollama_model(url, model):
|
||||
return True
|
||||
|
||||
|
||||
# LLM Markov Attack
|
||||
def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count):
|
||||
# LLM Ollama Attack
|
||||
def hcatOllama(hcatHashType, hcatHashFile, mode, context_data):
|
||||
global hcatProcess
|
||||
candidates_path = f"{hcatHashFile}.markov_candidates"
|
||||
hcstat2_raw_path = f"{hcatHashFile}.hcstat2_raw"
|
||||
hcstat2_path = f"{hcatHashFile}.hcstat2"
|
||||
hcstat2gen_path = os.path.join(
|
||||
hate_path, "hashcat-utils", "bin", hcatHcstat2genBin
|
||||
)
|
||||
|
||||
if not os.path.isfile(hcstat2gen_path):
|
||||
print(
|
||||
f"Error: hcstat2gen not found at {hcstat2gen_path}. "
|
||||
"LLM Markov attacks are not available."
|
||||
)
|
||||
return
|
||||
candidates_path = f"{hcatHashFile}.ollama_candidates"
|
||||
|
||||
# Step A: Build LLM prompt based on mode
|
||||
if mode == "wordlist":
|
||||
@@ -1577,19 +1561,18 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count):
|
||||
wordlist_sample = "\n".join(sample_lines)
|
||||
prompt = (
|
||||
"You are a password generation expert. Below is a sample of real passwords. "
|
||||
"Study the patterns, character choices, and structures. Generate exactly "
|
||||
f"{candidate_count} new unique passwords that follow similar patterns but "
|
||||
"are NOT copies of the input. Output ONLY passwords, one per line, no "
|
||||
"numbering or explanation.\n\n"
|
||||
f"Sample passwords:\n{wordlist_sample}"
|
||||
"Study the patterns, character choices, and structures. Then generate hashcat rules" \
|
||||
" that could transform common base words into similar passwords. Focus on patterns like " \
|
||||
"capitalization, leetspeak, suffixes, and common substitutions. Here are the sample passwords:\n" \
|
||||
f"{wordlist_sample}"
|
||||
)
|
||||
elif mode == "target":
|
||||
company = context_data.get("company", "")
|
||||
industry = context_data.get("industry", "")
|
||||
location = context_data.get("location", "")
|
||||
prompt = (
|
||||
f"Generate exactly {candidate_count} realistic passwords that employees at "
|
||||
f"{company} in the {industry} industry located in {location} might use. "
|
||||
"Generate baseword to be used in a denylist for keeping users from setting their passwords with these basewords. We are protecting the employees of "
|
||||
f"{company} in the {industry} industry located in {location}.\n\n"
|
||||
"Include variations with:\n"
|
||||
"- Company name variations and abbreviations\n"
|
||||
"- Common password patterns (Season+Year, Name+Numbers)\n"
|
||||
@@ -1599,11 +1582,11 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count):
|
||||
"Output ONLY the passwords, one per line, no numbering or explanation."
|
||||
)
|
||||
else:
|
||||
print(f"Error: Unknown Markov generation mode: {mode}")
|
||||
print(f"Error: Unknown LLM generation mode: {mode}")
|
||||
return
|
||||
|
||||
# Step B: Call Ollama API to generate candidates
|
||||
print(f"Generating {candidate_count} password candidates via Ollama ({ollamaModel})...")
|
||||
print(f"Generating password candidates via Ollama ({ollamaModel})...")
|
||||
api_url = f"{ollamaUrl}/api/generate"
|
||||
payload = json.dumps({
|
||||
"model": ollamaModel,
|
||||
@@ -1642,7 +1625,7 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count):
|
||||
print(f"Error calling Ollama API after pull: {retry_err}")
|
||||
return
|
||||
else:
|
||||
print(f"Could not pull model '{ollamaModel}'. Aborting Markov attack.")
|
||||
print(f"Could not pull model '{ollamaModel}'. Aborting LLM attack.")
|
||||
return
|
||||
else:
|
||||
print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}")
|
||||
@@ -1688,48 +1671,8 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count):
|
||||
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 hcstat2gen to build Markov stats
|
||||
print("Building Markov statistics with hcstat2gen...")
|
||||
try:
|
||||
with open(candidates_path, "rb") as candidates_file:
|
||||
hcatProcess = subprocess.Popen(
|
||||
[hcstat2gen_path, hcstat2_raw_path],
|
||||
stdin=candidates_file,
|
||||
)
|
||||
try:
|
||||
hcatProcess.wait()
|
||||
except KeyboardInterrupt:
|
||||
print("Killing PID {0}...".format(str(hcatProcess.pid)))
|
||||
hcatProcess.kill()
|
||||
return
|
||||
except Exception as e:
|
||||
print(f"Error running hcstat2gen: {e}")
|
||||
return
|
||||
|
||||
if not os.path.isfile(hcstat2_raw_path):
|
||||
print("Error: hcstat2gen did not produce output file.")
|
||||
return
|
||||
|
||||
# Step D: LZMA compress the raw hcstat2 file
|
||||
print("Compressing hcstat2 file with LZMA...")
|
||||
try:
|
||||
with open(hcstat2_raw_path, "rb") as raw_file:
|
||||
raw_data = raw_file.read()
|
||||
compressed = lzma.compress(
|
||||
raw_data,
|
||||
format=lzma.FORMAT_RAW,
|
||||
filters=[{"id": lzma.FILTER_LZMA1}],
|
||||
)
|
||||
with open(hcstat2_path, "wb") as out_file:
|
||||
out_file.write(compressed)
|
||||
except Exception as e:
|
||||
print(f"Error compressing hcstat2 file: {e}")
|
||||
return
|
||||
|
||||
print(f"Markov statistics file created -> {hcstat2_path}")
|
||||
|
||||
# Step E: Run hashcat mask attack with custom Markov stats
|
||||
print("Running Markov-enhanced mask attack...")
|
||||
# Step C: Run hashcat wordlist attack with LLM-generated candidates (no rules)
|
||||
print("Running wordlist attack with LLM-generated candidates...")
|
||||
cmd = [
|
||||
hcatBin,
|
||||
"-m",
|
||||
@@ -1739,13 +1682,7 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count):
|
||||
generate_session_id(),
|
||||
"-o",
|
||||
f"{hcatHashFile}.out",
|
||||
"-a",
|
||||
"3",
|
||||
f"--markov-hcstat2={hcstat2_path}",
|
||||
"--increment",
|
||||
"--increment-min=1",
|
||||
"--increment-max=14",
|
||||
"?a?a?a?a?a?a?a?a?a?a?a?a?a?a",
|
||||
candidates_path,
|
||||
]
|
||||
cmd.extend(shlex.split(hcatTuning))
|
||||
_append_potfile_arg(cmd)
|
||||
@@ -1755,14 +1692,43 @@ def hcatMarkov(hcatHashType, hcatHashFile, mode, context_data, candidate_count):
|
||||
except KeyboardInterrupt:
|
||||
print("Killing PID {0}...".format(str(hcatProcess.pid)))
|
||||
hcatProcess.kill()
|
||||
return
|
||||
|
||||
# Step F: Cleanup temp files
|
||||
for temp_file in [candidates_path, hcstat2_raw_path]:
|
||||
# Step D: Run hashcat with LLM 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.")
|
||||
return
|
||||
|
||||
print(f"\nRunning LLM candidates with {len(rule_files)} rule file(s) from {rulesDirectory}...")
|
||||
for rule in rule_files:
|
||||
rule_path = os.path.join(rulesDirectory, rule)
|
||||
print(f"\n\tRunning with rule: {rule}")
|
||||
cmd = [
|
||||
hcatBin,
|
||||
"-m",
|
||||
hcatHashType,
|
||||
hcatHashFile,
|
||||
"--session",
|
||||
generate_session_id(),
|
||||
"-o",
|
||||
f"{hcatHashFile}.out",
|
||||
"-r",
|
||||
rule_path,
|
||||
candidates_path,
|
||||
]
|
||||
cmd.extend(shlex.split(hcatTuning))
|
||||
_append_potfile_arg(cmd)
|
||||
hcatProcess = subprocess.Popen(cmd)
|
||||
try:
|
||||
if os.path.isfile(temp_file):
|
||||
os.remove(temp_file)
|
||||
except Exception:
|
||||
pass
|
||||
hcatProcess.wait()
|
||||
except KeyboardInterrupt:
|
||||
print("Killing PID {0}...".format(str(hcatProcess.pid)))
|
||||
hcatProcess.kill()
|
||||
return
|
||||
|
||||
|
||||
|
||||
# Middle fast Combinator Attack
|
||||
@@ -2280,13 +2246,10 @@ def hashview_api():
|
||||
menu_options.append(("download_wordlist", "Download Wordlist"))
|
||||
menu_options.append(
|
||||
(
|
||||
"download_left",
|
||||
"Download Left Hashes (with automatic merge if found)",
|
||||
"download_hashes",
|
||||
"Download Hashes (left + found to potfile)",
|
||||
)
|
||||
)
|
||||
menu_options.append(
|
||||
("download_found", "Download Found Hashes (with automatic split)")
|
||||
)
|
||||
if hcatHashFile:
|
||||
menu_options.append(
|
||||
("upload_hashfile_job", "Upload Hashfile and Create Job")
|
||||
@@ -2683,7 +2646,7 @@ def hashview_api():
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error uploading hashfile: {str(e)}")
|
||||
|
||||
elif option_key == "download_left":
|
||||
elif option_key == "download_hashes":
|
||||
# Download left hashes
|
||||
try:
|
||||
while True:
|
||||
@@ -2852,157 +2815,6 @@ def hashview_api():
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error downloading hashes: {str(e)}")
|
||||
|
||||
elif option_key == "download_found":
|
||||
# Download found hashes
|
||||
try:
|
||||
while True:
|
||||
# First, list customers to help user select
|
||||
customers_result = api_harness.list_customers()
|
||||
customers = (
|
||||
customers_result.get("customers", [])
|
||||
if isinstance(customers_result, dict)
|
||||
else customers_result
|
||||
)
|
||||
if customers:
|
||||
api_harness.display_customers_multicolumn(customers)
|
||||
else:
|
||||
print("\nNo customers found.")
|
||||
|
||||
# Select or create customer
|
||||
customer_input = input(
|
||||
"\nEnter customer ID or N to create new: "
|
||||
).strip()
|
||||
if customer_input.lower() == "n":
|
||||
customer_name = input("Enter customer name: ").strip()
|
||||
if customer_name:
|
||||
try:
|
||||
result = api_harness.create_customer(customer_name)
|
||||
print(
|
||||
f"\n✓ Success: {result.get('msg', 'Customer created')}"
|
||||
)
|
||||
customer_id = result.get(
|
||||
"customer_id"
|
||||
) or result.get("id")
|
||||
if not customer_id:
|
||||
print("\n✗ Error: Customer ID not returned.")
|
||||
continue
|
||||
print(f" Customer ID: {customer_id}")
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error creating customer: {str(e)}")
|
||||
continue
|
||||
else:
|
||||
print("\n✗ Error: Customer name cannot be empty.")
|
||||
continue
|
||||
else:
|
||||
try:
|
||||
customer_id = int(customer_input)
|
||||
except ValueError:
|
||||
print(
|
||||
"\n✗ Error: Invalid ID entered. Please enter a numeric ID or N."
|
||||
)
|
||||
continue
|
||||
|
||||
# List hashfiles for the customer
|
||||
try:
|
||||
customer_hashfiles = api_harness.get_customer_hashfiles(
|
||||
customer_id
|
||||
)
|
||||
|
||||
if not customer_hashfiles:
|
||||
print(
|
||||
f"\nNo hashfiles found for customer ID {customer_id}"
|
||||
)
|
||||
continue
|
||||
|
||||
print("\n" + "=" * 120)
|
||||
print(f"Hashfiles for Customer ID {customer_id}:")
|
||||
print("=" * 120)
|
||||
print(f"{'ID':<10} {'Hash Type':<10} {'Name':<96}")
|
||||
print("-" * 120)
|
||||
hashfile_map = {}
|
||||
for hf in customer_hashfiles:
|
||||
hf_id = hf.get("id")
|
||||
hf_name = hf.get("name", "N/A")
|
||||
hf_type = (
|
||||
hf.get("hash_type") or hf.get("hashtype") or "N/A"
|
||||
)
|
||||
if hf_id is None:
|
||||
continue
|
||||
# Truncate long names to fit within 120 columns
|
||||
if len(str(hf_name)) > 96:
|
||||
hf_name = str(hf_name)[:93] + "..."
|
||||
if debug_mode:
|
||||
print(
|
||||
f"[DEBUG] Hashfile {hf_id}: hash_type={hf.get('hash_type')}, hashtype={hf.get('hashtype')}, combined={hf_type}"
|
||||
)
|
||||
print(f"{hf_id:<10} {hf_type:<10} {hf_name:<96}")
|
||||
hashfile_map[int(hf_id)] = hf_type
|
||||
print("=" * 120)
|
||||
print(f"Total: {len(hashfile_map)} hashfile(s)")
|
||||
except Exception as e:
|
||||
print(f"\nWarning: Could not list hashfiles: {e}")
|
||||
continue
|
||||
|
||||
while True:
|
||||
try:
|
||||
hashfile_id_input = input(
|
||||
"\nEnter hashfile ID: "
|
||||
).strip()
|
||||
hashfile_id = int(hashfile_id_input)
|
||||
except ValueError:
|
||||
print(
|
||||
"\n✗ Error: Invalid ID entered. Please enter a numeric ID."
|
||||
)
|
||||
continue
|
||||
if hashfile_id not in hashfile_map:
|
||||
print(
|
||||
"\n✗ Error: Hashfile ID not in the list. Please try again."
|
||||
)
|
||||
continue
|
||||
break
|
||||
break
|
||||
|
||||
# Set output filename automatically
|
||||
output_file = f"found_{customer_id}_{hashfile_id}.txt"
|
||||
|
||||
# Get hash type for hashcat from the hashfile map
|
||||
selected_hash_type = hashfile_map.get(hashfile_id)
|
||||
if debug_mode:
|
||||
print(
|
||||
f"[DEBUG] selected_hash_type from map: {selected_hash_type}"
|
||||
)
|
||||
if not selected_hash_type or selected_hash_type == "N/A":
|
||||
try:
|
||||
details = api_harness.get_hashfile_details(hashfile_id)
|
||||
selected_hash_type = details.get("hashtype")
|
||||
if debug_mode:
|
||||
print(
|
||||
f"[DEBUG] selected_hash_type from get_hashfile_details: {selected_hash_type}"
|
||||
)
|
||||
except Exception as e:
|
||||
if debug_mode:
|
||||
print(f"[DEBUG] Error fetching hashfile details: {e}")
|
||||
selected_hash_type = None
|
||||
|
||||
# Download the found hashes
|
||||
if debug_mode:
|
||||
print(
|
||||
f"[DEBUG] Calling download_found_hashes with hash_type={selected_hash_type}"
|
||||
)
|
||||
download_result = api_harness.download_found_hashes(
|
||||
customer_id, hashfile_id, output_file
|
||||
)
|
||||
print(f"\n✓ Success: Downloaded {download_result['size']} bytes")
|
||||
print(f" File: {download_result['output_file']}")
|
||||
if selected_hash_type:
|
||||
print(f" Hash mode: {selected_hash_type}")
|
||||
print("\nFound hashes downloaded successfully. These are already cracked hashes.")
|
||||
|
||||
except ValueError:
|
||||
print("\n✗ Error: Invalid ID entered. Please enter a numeric ID.")
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error downloading found hashes: {str(e)}")
|
||||
|
||||
elif option_key == "back":
|
||||
break
|
||||
|
||||
@@ -3075,8 +2887,8 @@ def loopback_attack():
|
||||
return _attacks.loopback_attack(_attack_ctx())
|
||||
|
||||
|
||||
def markov_attack():
|
||||
return _attacks.markov_attack(_attack_ctx())
|
||||
def ollama_attack():
|
||||
return _attacks.ollama_attack(_attack_ctx())
|
||||
|
||||
|
||||
# convert hex words for recycling
|
||||
@@ -3306,7 +3118,7 @@ def get_main_menu_options():
|
||||
"12": thorough_combinator,
|
||||
"13": bandrel_method,
|
||||
"14": loopback_attack,
|
||||
"15": markov_attack,
|
||||
"15": ollama_attack,
|
||||
"90": download_hashmob_rules,
|
||||
"91": analyze_rules,
|
||||
"92": download_hashmob_wordlists,
|
||||
@@ -3448,8 +3260,8 @@ def main():
|
||||
)
|
||||
|
||||
hv_download_left = hashview_subparsers.add_parser(
|
||||
"download-left",
|
||||
help="Download left hashes for a hashfile",
|
||||
"download-hashes",
|
||||
help="Download left hashes and append found hashes to potfile",
|
||||
)
|
||||
hv_download_left.add_argument(
|
||||
"--customer-id", required=True, type=int, help="Customer ID"
|
||||
@@ -3463,17 +3275,6 @@ def main():
|
||||
help="Hash type for hashcat (e.g., 1000 for NTLM)",
|
||||
)
|
||||
|
||||
hv_download_found = hashview_subparsers.add_parser(
|
||||
"download-found",
|
||||
help="Download found hashes for a hashfile",
|
||||
)
|
||||
hv_download_found.add_argument(
|
||||
"--customer-id", required=True, type=int, help="Customer ID"
|
||||
)
|
||||
hv_download_found.add_argument(
|
||||
"--hashfile-id", required=True, type=int, help="Hashfile ID"
|
||||
)
|
||||
|
||||
hv_upload_hashfile_job = hashview_subparsers.add_parser(
|
||||
"upload-hashfile-job",
|
||||
help="Upload a hashfile and create a job",
|
||||
@@ -3514,8 +3315,7 @@ def main():
|
||||
hashview_subcommands = [
|
||||
"upload-cracked",
|
||||
"upload-wordlist",
|
||||
"download-left",
|
||||
"download-found",
|
||||
"download-hashes",
|
||||
"upload-hashfile-job",
|
||||
]
|
||||
has_hashview_flag = "--hashview" in argv
|
||||
@@ -3639,7 +3439,7 @@ def main():
|
||||
print(f" Wordlist ID: {result['wordlist_id']}")
|
||||
sys.exit(0)
|
||||
|
||||
if args.hashview_command == "download-left":
|
||||
if args.hashview_command == "download-hashes":
|
||||
download_result = api_harness.download_left_hashes(
|
||||
args.customer_id,
|
||||
args.hashfile_id,
|
||||
@@ -3649,15 +3449,6 @@ def main():
|
||||
print(f" File: {download_result['output_file']}")
|
||||
sys.exit(0)
|
||||
|
||||
if args.hashview_command == "download-found":
|
||||
download_result = api_harness.download_found_hashes(
|
||||
args.customer_id,
|
||||
args.hashfile_id,
|
||||
)
|
||||
print(f"\n✓ Success: Downloaded {download_result['size']} bytes")
|
||||
print(f" File: {download_result['output_file']}")
|
||||
sys.exit(0)
|
||||
|
||||
if args.hashview_command == "upload-hashfile-job":
|
||||
hashfile_path = resolve_path(args.file)
|
||||
if not hashfile_path or not os.path.isfile(hashfile_path):
|
||||
@@ -3887,7 +3678,7 @@ def main():
|
||||
print("\t(12) Thorough Combinator Attack")
|
||||
print("\t(13) Bandrel Methodology")
|
||||
print("\t(14) Loopback Attack")
|
||||
print("\t(15) LLM Markov Attack")
|
||||
print("\t(15) LLM 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")
|
||||
|
||||
+4
-54
@@ -270,52 +270,6 @@ class TestHashviewAPI:
|
||||
assert "Cookie" in auth_headers or "uuid" in str(auth_headers)
|
||||
assert HASHVIEW_API_KEY in str(auth_headers)
|
||||
|
||||
def test_download_found_hashes(self, api, tmp_path):
|
||||
"""Test downloading found hashes: real API if possible, else mock."""
|
||||
hashview_url, hashview_api_key = self._get_hashview_config()
|
||||
customer_id = os.environ.get("HASHVIEW_CUSTOMER_ID")
|
||||
hashfile_id = os.environ.get("HASHVIEW_HASHFILE_ID")
|
||||
if all([hashview_url, hashview_api_key, customer_id, hashfile_id]):
|
||||
# Real API test
|
||||
real_api = HashviewAPI(hashview_url, hashview_api_key)
|
||||
output_file = tmp_path / f"found_{customer_id}_{hashfile_id}.txt"
|
||||
result = real_api.download_found_hashes(
|
||||
int(customer_id), int(hashfile_id), output_file=str(output_file)
|
||||
)
|
||||
assert os.path.exists(result["output_file"])
|
||||
with open(result["output_file"], "rb") as f:
|
||||
content = f.read()
|
||||
print(f"[DEBUG] Downloaded {len(content)} bytes to {result['output_file']}")
|
||||
assert result["size"] == len(content)
|
||||
else:
|
||||
# Mock test
|
||||
mock_response = Mock()
|
||||
mock_response.content = b"hash1:pass1\nhash2:pass2\n"
|
||||
mock_response.raise_for_status = Mock()
|
||||
mock_response.headers = {"content-length": "0"}
|
||||
|
||||
def iter_content(chunk_size=8192):
|
||||
yield mock_response.content
|
||||
|
||||
mock_response.iter_content = iter_content
|
||||
api.session.get.return_value = mock_response
|
||||
|
||||
output_file = tmp_path / "found_1_2.txt"
|
||||
result = api.download_found_hashes(1, 2, output_file=str(output_file))
|
||||
assert os.path.exists(result["output_file"])
|
||||
with open(result["output_file"], "rb") as f:
|
||||
content = f.read()
|
||||
assert content == b"hash1:pass1\nhash2:pass2\n"
|
||||
assert result["size"] == len(content)
|
||||
|
||||
# Verify auth headers were passed in the found hashes download call
|
||||
call_args_list = api.session.get.call_args_list
|
||||
found_call = [c for c in call_args_list if "found" in str(c)][0]
|
||||
assert found_call.kwargs.get("headers") is not None
|
||||
auth_headers = found_call.kwargs.get("headers")
|
||||
assert "Cookie" in auth_headers or "uuid" in str(auth_headers)
|
||||
assert HASHVIEW_API_KEY in str(auth_headers)
|
||||
|
||||
def test_download_wordlist(self, api, tmp_path):
|
||||
"""Test downloading a wordlist: real API if possible, else mock."""
|
||||
hashview_url, hashview_api_key = self._get_hashview_config()
|
||||
@@ -640,21 +594,17 @@ class TestHashviewAPI:
|
||||
# Verify left file was created
|
||||
assert os.path.exists(result["output_file"])
|
||||
|
||||
# Verify found file was downloaded and deleted
|
||||
# Verify found files are cleaned up after merge
|
||||
found_file = tmp_path / "found_1_2.txt"
|
||||
assert not os.path.exists(found_file), (
|
||||
"Found file should be deleted after split"
|
||||
)
|
||||
assert not (other_cwd / "found_1_2.txt").exists()
|
||||
assert not os.path.exists(found_file), "Found file should be deleted after merge"
|
||||
|
||||
# Verify split files were created and deleted
|
||||
found_hashes_file = tmp_path / "found_hashes_1_2.txt"
|
||||
found_clears_file = tmp_path / "found_clears_1_2.txt"
|
||||
assert not os.path.exists(str(found_hashes_file)), (
|
||||
"Split hashes file should be deleted"
|
||||
"Split hashes file should be deleted after merge"
|
||||
)
|
||||
assert not os.path.exists(str(found_clears_file)), (
|
||||
"Split clears file should be deleted"
|
||||
"Split clears file should be deleted after merge"
|
||||
)
|
||||
|
||||
def test_download_left_id_matching(self, api, tmp_path):
|
||||
|
||||
@@ -28,15 +28,6 @@ class DummyHashviewAPI:
|
||||
"size": 10,
|
||||
}
|
||||
|
||||
def download_found_hashes(self, customer_id, hashfile_id, output_file=None):
|
||||
self.calls.append(
|
||||
("download_found_hashes", customer_id, hashfile_id, output_file)
|
||||
)
|
||||
return {
|
||||
"output_file": output_file or f"found_{customer_id}_{hashfile_id}.txt",
|
||||
"size": 12,
|
||||
}
|
||||
|
||||
def upload_hashfile(
|
||||
self, file_path, customer_id, hash_type, file_format=5, hashfile_name=None
|
||||
):
|
||||
|
||||
@@ -77,8 +77,7 @@ def _ensure_customer_one():
|
||||
"--name",
|
||||
"TestWordlist",
|
||||
],
|
||||
["hashview", "download-left", "--customer-id", "1", "--hashfile-id", "2"],
|
||||
["hashview", "download-found", "--customer-id", "1", "--hashfile-id", "2"],
|
||||
["hashview", "download-hashes", "--customer-id", "1", "--hashfile-id", "2"],
|
||||
[
|
||||
"hashview",
|
||||
"upload-hashfile-job",
|
||||
@@ -142,45 +141,25 @@ def test_hashview_subcommands_live_downloads():
|
||||
base_cmd = [sys.executable, HATE_CRACK_SCRIPT, "hashview"]
|
||||
customer_id = _ensure_customer_one()
|
||||
|
||||
left_cmd = base_cmd + [
|
||||
"download-left",
|
||||
dl_cmd = base_cmd + [
|
||||
"download-hashes",
|
||||
"--customer-id",
|
||||
str(customer_id),
|
||||
"--hashfile-id",
|
||||
os.environ["HASHVIEW_HASHFILE_ID"],
|
||||
]
|
||||
left = subprocess.run(
|
||||
left_cmd,
|
||||
dl = subprocess.run(
|
||||
dl_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
)
|
||||
left_out = left.stdout + left.stderr
|
||||
assert left.returncode == 0, left_out
|
||||
assert "Downloaded" in left_out
|
||||
assert "left_" in left_out
|
||||
|
||||
found_cmd = base_cmd + [
|
||||
"download-found",
|
||||
"--customer-id",
|
||||
str(customer_id),
|
||||
"--hashfile-id",
|
||||
os.environ["HASHVIEW_HASHFILE_ID"],
|
||||
]
|
||||
found = subprocess.run(
|
||||
found_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
cwd=REPO_ROOT,
|
||||
env=env,
|
||||
)
|
||||
found_out = found.stdout + found.stderr
|
||||
assert found.returncode == 0, found_out
|
||||
assert "Downloaded" in found_out
|
||||
assert "found_" in found_out
|
||||
dl_out = dl.stdout + dl.stderr
|
||||
assert dl.returncode == 0, dl_out
|
||||
assert "Downloaded" in dl_out
|
||||
assert "left_" in dl_out
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
|
||||
+193
-348
@@ -1,8 +1,7 @@
|
||||
"""Unit tests for _pull_ollama_model helper, hcatMarkov steps A-F, and markov_attack handler."""
|
||||
"""Unit tests for _pull_ollama_model helper, hcatOllama steps A-C, and ollama_attack handler."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import lzma
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -25,36 +24,31 @@ MODEL = "llama3.2"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def markov_env(tmp_path):
|
||||
"""Create the filesystem layout hcatMarkov expects."""
|
||||
def ollama_env(tmp_path):
|
||||
"""Create the filesystem layout hcatOllama expects."""
|
||||
hash_file = tmp_path / "hashes.txt"
|
||||
hash_file.touch()
|
||||
|
||||
hcutil_bin = tmp_path / "hashcat-utils" / "bin"
|
||||
hcutil_bin.mkdir(parents=True)
|
||||
hcstat2gen = hcutil_bin / hc_main.hcatHcstat2genBin
|
||||
hcstat2gen.touch()
|
||||
|
||||
wordlist = tmp_path / "sample.txt"
|
||||
wordlist.write_text("password\n123456\nletmein\n")
|
||||
|
||||
return SimpleNamespace(
|
||||
tmp_path=tmp_path,
|
||||
hash_file=str(hash_file),
|
||||
hcstat2gen=str(hcstat2gen),
|
||||
wordlist=str(wordlist),
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def markov_globals(tmp_path, tuning="", potfile=""):
|
||||
"""Patch the hc_main globals that hcatMarkov reads."""
|
||||
def ollama_globals(tmp_path, tuning="", potfile=""):
|
||||
"""Patch the hc_main globals that hcatOllama reads."""
|
||||
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, "hate_path", str(tmp_path)), \
|
||||
mock.patch("hate_crack.main.generate_session_id", return_value="test_session"):
|
||||
yield
|
||||
|
||||
|
||||
@@ -184,11 +178,11 @@ class TestPullOllamaModel:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# hcatMarkov 404-retry integration tests
|
||||
# hcatOllama 404-retry integration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHcatMarkov404Retry:
|
||||
"""Test that hcatMarkov auto-pulls on 404 and retries the generate call."""
|
||||
class TestHcatOllama404Retry:
|
||||
"""Test that hcatOllama auto-pulls on 404 and retries the generate call."""
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
MODEL = "llama3.2"
|
||||
@@ -198,16 +192,10 @@ class TestHcatMarkov404Retry:
|
||||
return io.BytesIO(body)
|
||||
|
||||
def _setup_env(self, tmp_path):
|
||||
"""Create the files/dirs hcatMarkov needs before it hits the API."""
|
||||
"""Create the files/dirs hcatOllama needs before it hits the API."""
|
||||
hash_file = str(tmp_path / "hashes.txt")
|
||||
open(hash_file, "w").close()
|
||||
|
||||
# hcatMarkov checks for hcstat2gen binary at startup
|
||||
hcutil_bin = tmp_path / "hashcat-utils" / "bin"
|
||||
hcutil_bin.mkdir(parents=True, exist_ok=True)
|
||||
hcstat2gen = hcutil_bin / hc_main.hcatHcstat2genBin
|
||||
hcstat2gen.touch()
|
||||
|
||||
# Create a small wordlist for "wordlist" mode
|
||||
wordlist = str(tmp_path / "sample.txt")
|
||||
with open(wordlist, "w") as f:
|
||||
@@ -245,12 +233,13 @@ class TestHcatMarkov404Retry:
|
||||
mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \
|
||||
mock.patch.object(hc_main, "hcatTuning", ""), \
|
||||
mock.patch.object(hc_main, "hate_path", str(tmp_path)), \
|
||||
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.hcatMarkov("0", hash_file, "wordlist", wordlist, 10)
|
||||
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
|
||||
@@ -259,7 +248,7 @@ class TestHcatMarkov404Retry:
|
||||
@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, hcatMarkov should abort gracefully."""
|
||||
"""If pull fails after 404, hcatOllama should abort gracefully."""
|
||||
hash_file, wordlist = self._setup_env(tmp_path)
|
||||
|
||||
mock_urlopen.side_effect = urllib.error.HTTPError(
|
||||
@@ -275,7 +264,7 @@ class TestHcatMarkov404Retry:
|
||||
mock.patch.object(hc_main, "ollamaModel", self.MODEL), \
|
||||
mock.patch.object(hc_main, "hate_path", str(tmp_path)):
|
||||
|
||||
hc_main.hcatMarkov("0", hash_file, "wordlist", wordlist, 10)
|
||||
hc_main.hcatOllama("0", hash_file, "wordlist", wordlist)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Could not pull model" in captured.out
|
||||
@@ -298,7 +287,7 @@ class TestHcatMarkov404Retry:
|
||||
mock.patch.object(hc_main, "ollamaModel", self.MODEL), \
|
||||
mock.patch.object(hc_main, "hate_path", str(tmp_path)):
|
||||
|
||||
hc_main.hcatMarkov("0", hash_file, "wordlist", wordlist, 10)
|
||||
hc_main.hcatOllama("0", hash_file, "wordlist", wordlist)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
# HTTPError is a subclass of URLError, so it hits the URLError handler
|
||||
@@ -310,51 +299,36 @@ class TestHcatMarkov404Retry:
|
||||
# Step A: Mode routing, prompt construction, early-return error paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHcatMarkovModeRouting:
|
||||
class TestHcatOllamaModeRouting:
|
||||
"""Test mode selection, prompt building, and early-return errors."""
|
||||
|
||||
def test_unknown_mode_prints_error(self, markov_env, capsys):
|
||||
def test_unknown_mode_prints_error(self, ollama_env, capsys):
|
||||
"""Bad mode string → error message, no API call."""
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
with ollama_globals(ollama_env.tmp_path), \
|
||||
mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url:
|
||||
hc_main.hcatMarkov("0", markov_env.hash_file, "bogus", "", 10)
|
||||
hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", "")
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Unknown Markov generation mode" in captured.out
|
||||
assert "Unknown LLM generation mode" in captured.out
|
||||
mock_url.assert_not_called()
|
||||
|
||||
def test_missing_wordlist_prints_error(self, markov_env, capsys):
|
||||
def test_missing_wordlist_prints_error(self, ollama_env, capsys):
|
||||
"""Non-existent wordlist path → error, no API call."""
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
with ollama_globals(ollama_env.tmp_path), \
|
||||
mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url:
|
||||
hc_main.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist",
|
||||
"/no/such/wordlist.txt", 10,
|
||||
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_missing_hcstat2gen_prints_error(self, tmp_path, capsys):
|
||||
"""No hcstat2gen binary → error, no API call."""
|
||||
hash_file = tmp_path / "hashes.txt"
|
||||
hash_file.touch()
|
||||
wordlist = tmp_path / "sample.txt"
|
||||
wordlist.write_text("password\n")
|
||||
|
||||
with markov_globals(tmp_path), \
|
||||
mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url:
|
||||
hc_main.hcatMarkov("0", str(hash_file), "wordlist", str(wordlist), 10)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "hcstat2gen not found" in captured.out
|
||||
mock_url.assert_not_called()
|
||||
|
||||
def test_wordlist_mode_reads_up_to_500_lines(self, markov_env, capsys):
|
||||
def test_wordlist_mode_reads_up_to_500_lines(self, ollama_env, capsys):
|
||||
"""Only the first 500 non-blank lines should appear in the prompt payload."""
|
||||
# Write 600 lines to the wordlist
|
||||
big_wordlist = markov_env.tmp_path / "big.txt"
|
||||
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 = {}
|
||||
@@ -363,13 +337,13 @@ class TestHcatMarkovModeRouting:
|
||||
captured_payload["data"] = json.loads(req.data.decode())
|
||||
return _generate_response(["Password1"])
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
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.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", str(big_wordlist), 10,
|
||||
hc_main.hcatOllama(
|
||||
"0", ollama_env.hash_file, "wordlist", str(big_wordlist),
|
||||
)
|
||||
|
||||
prompt_text = captured_payload["data"]["prompt"]
|
||||
@@ -377,7 +351,7 @@ class TestHcatMarkovModeRouting:
|
||||
assert "pass499" in prompt_text
|
||||
assert "pass500" not in prompt_text
|
||||
|
||||
def test_target_mode_includes_context_in_prompt(self, markov_env, capsys):
|
||||
def test_target_mode_includes_context_in_prompt(self, ollama_env, capsys):
|
||||
"""Company, industry, and location should appear in the prompt."""
|
||||
captured_payload = {}
|
||||
|
||||
@@ -390,13 +364,13 @@ class TestHcatMarkovModeRouting:
|
||||
"industry": "Finance",
|
||||
"location": "New York",
|
||||
}
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
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.hcatMarkov(
|
||||
"0", markov_env.hash_file, "target", target_info, 10,
|
||||
hc_main.hcatOllama(
|
||||
"0", ollama_env.hash_file, "target", target_info,
|
||||
)
|
||||
|
||||
prompt_text = captured_payload["data"]["prompt"]
|
||||
@@ -409,33 +383,37 @@ class TestHcatMarkovModeRouting:
|
||||
# Step B: Candidate filtering / post-processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHcatMarkovCandidateFiltering:
|
||||
class TestHcatOllamaCandidateFiltering:
|
||||
"""Test regex stripping, blank-line removal, 128-char limit, empty-result handling."""
|
||||
|
||||
def _run_with_response(self, markov_env, response_text):
|
||||
"""Run hcatMarkov with a canned Ollama response_text, return candidates file content."""
|
||||
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)
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
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") as mock_popen:
|
||||
proc = _make_proc()
|
||||
mock_popen.return_value = proc
|
||||
hc_main.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
mock.patch("subprocess.Popen", side_effect=capture_popen):
|
||||
hc_main.hcatOllama(
|
||||
"0", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
|
||||
)
|
||||
|
||||
candidates_path = f"{markov_env.hash_file}.markov_candidates"
|
||||
if os.path.isfile(candidates_path):
|
||||
with open(candidates_path) as f:
|
||||
return f.read()
|
||||
return None
|
||||
return captured.get("content")
|
||||
|
||||
def test_strips_numeric_dot_prefix(self, markov_env):
|
||||
content = self._run_with_response(markov_env, "1. Password1\n2. Summer2024")
|
||||
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
|
||||
@@ -445,51 +423,51 @@ class TestHcatMarkovCandidateFiltering:
|
||||
assert not line.startswith("1.")
|
||||
assert not line.startswith("2.")
|
||||
|
||||
def test_strips_dash_and_asterisk_prefix(self, markov_env):
|
||||
content = self._run_with_response(markov_env, "- foo\n* bar")
|
||||
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, markov_env):
|
||||
content = self._run_with_response(markov_env, "alpha\n\n\nbeta\n\n")
|
||||
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, markov_env):
|
||||
def test_rejects_over_128_chars(self, ollama_env):
|
||||
long_pw = "A" * 129
|
||||
content = self._run_with_response(markov_env, f"short\n{long_pw}\nkeep")
|
||||
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, markov_env):
|
||||
def test_accepts_exactly_128_chars(self, ollama_env):
|
||||
exact_pw = "B" * 128
|
||||
content = self._run_with_response(markov_env, f"{exact_pw}\nother")
|
||||
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, markov_env, capsys):
|
||||
self._run_with_response(markov_env, "")
|
||||
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, markov_env, capsys):
|
||||
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 markov_globals(markov_env.tmp_path), \
|
||||
with ollama_globals(ollama_env.tmp_path), \
|
||||
mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp):
|
||||
hc_main.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
hc_main.hcatOllama(
|
||||
"0", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
@@ -500,37 +478,37 @@ class TestHcatMarkovCandidateFiltering:
|
||||
# Step B: API error paths (non-404)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHcatMarkovApiErrors:
|
||||
class TestHcatOllamaApiErrors:
|
||||
"""Test connection errors and generic exceptions during the generate call."""
|
||||
|
||||
def test_url_error_prints_connection_error(self, markov_env, capsys):
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
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.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
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, markov_env, capsys):
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
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.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
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, markov_env):
|
||||
def test_generate_request_payload(self, ollama_env):
|
||||
"""Verify the /api/generate request has correct URL, model, stream=false."""
|
||||
captured_req = {}
|
||||
|
||||
@@ -539,13 +517,13 @@ class TestHcatMarkovApiErrors:
|
||||
captured_req["body"] = json.loads(req.data.decode())
|
||||
return _generate_response(["Password1"])
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
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.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
hc_main.hcatOllama(
|
||||
"0", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
|
||||
)
|
||||
|
||||
assert captured_req["url"] == f"{OLLAMA_URL}/api/generate"
|
||||
@@ -555,269 +533,136 @@ class TestHcatMarkovApiErrors:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Steps C + D: hcstat2gen execution and LZMA compression
|
||||
# Step C: Hashcat command construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHcatMarkovHcstat2gen:
|
||||
"""Test hcstat2gen subprocess step."""
|
||||
class TestHcatOllamaHashcatCommand:
|
||||
"""Test hashcat command flags and process handling."""
|
||||
|
||||
def test_hcstat2gen_correct_args(self, markov_env):
|
||||
"""hcstat2gen should be called with [binary_path, raw_output_path] and stdin=file."""
|
||||
hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw"
|
||||
hcstat2gen_path = markov_env.hcstat2gen
|
||||
|
||||
call_args_list = []
|
||||
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):
|
||||
call_args_list.append((list(cmd), dict(kwargs)))
|
||||
proc = _make_proc()
|
||||
# Create raw hcstat2 file so the LZMA compression step continues
|
||||
if cmd[0] == hcstat2gen_path:
|
||||
with open(hcstat2_raw_path, "wb") as f:
|
||||
f.write(b"\x00" * 100)
|
||||
return proc
|
||||
popen_calls.append((list(cmd), dict(kwargs)))
|
||||
return _make_proc()
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
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.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
hc_main.hcatOllama(
|
||||
"1000", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
|
||||
)
|
||||
|
||||
# First Popen call should be hcstat2gen writing to the raw path
|
||||
first_cmd, first_kwargs = call_args_list[0]
|
||||
assert first_cmd[0] == hcstat2gen_path
|
||||
assert first_cmd[1] == hcstat2_raw_path
|
||||
assert "stdin" in first_kwargs
|
||||
return popen_calls
|
||||
|
||||
def test_hcstat2gen_keyboard_interrupt(self, markov_env, capsys):
|
||||
"""KeyboardInterrupt during hcstat2gen should kill the process and return."""
|
||||
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.communicate.side_effect = KeyboardInterrupt()
|
||||
proc.pid = 12345
|
||||
proc.pid = 99999
|
||||
proc.wait.side_effect = KeyboardInterrupt()
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
with ollama_globals(ollama_env.tmp_path), \
|
||||
_urlopen_with_response(["Password1"]), \
|
||||
mock.patch("subprocess.Popen", return_value=proc):
|
||||
hc_main.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
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()
|
||||
|
||||
def test_hcstat2gen_no_output_file(self, markov_env, capsys):
|
||||
"""If hcstat2gen doesn't produce a raw output file, abort with error."""
|
||||
proc = mock.MagicMock()
|
||||
proc.communicate.return_value = (b"", b"")
|
||||
proc.returncode = 0
|
||||
# Don't create hcstat2_raw_path → triggers the "did not produce" error
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
_urlopen_with_response(["Password1"]), \
|
||||
mock.patch("subprocess.Popen", return_value=proc):
|
||||
hc_main.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "did not produce output file" in captured.out
|
||||
|
||||
def test_lzma_compression_error(self, markov_env, capsys):
|
||||
"""LZMAError during compression → error message, no hashcat run."""
|
||||
hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw"
|
||||
|
||||
def track_popen(cmd, **kwargs):
|
||||
proc = _make_proc()
|
||||
if cmd[0] == markov_env.hcstat2gen:
|
||||
with open(hcstat2_raw_path, "wb") as f:
|
||||
f.write(b"\x00" * 100)
|
||||
return proc
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
_urlopen_with_response(["Password1"]), \
|
||||
mock.patch("subprocess.Popen", side_effect=track_popen) as mock_popen, \
|
||||
mock.patch("hate_crack.main.lzma.compress", side_effect=lzma.LZMAError("bad data")):
|
||||
hc_main.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Error compressing" in captured.out
|
||||
# Should only have 1 Popen call (hcstat2gen), NOT a second for hashcat
|
||||
assert mock_popen.call_count == 1
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step E: Hashcat command construction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHcatMarkovHashcatCommand:
|
||||
"""Test hashcat command flags and process handling."""
|
||||
|
||||
def _run_full(self, markov_env, tuning="", potfile=""):
|
||||
"""Run hcatMarkov through all steps, returning the list of Popen calls."""
|
||||
hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw"
|
||||
popen_calls = []
|
||||
|
||||
def track_popen(cmd, **kwargs):
|
||||
popen_calls.append((list(cmd), dict(kwargs)))
|
||||
proc = _make_proc()
|
||||
# hcstat2gen: create the raw output file (LZMA2 compression step will follow)
|
||||
if cmd[0] == markov_env.hcstat2gen:
|
||||
with open(hcstat2_raw_path, "wb") as f:
|
||||
f.write(b"\x00" * 100)
|
||||
return proc
|
||||
|
||||
with markov_globals(markov_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.hcatMarkov(
|
||||
"1000", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
)
|
||||
|
||||
return popen_calls
|
||||
|
||||
def test_hashcat_command_structure(self, markov_env):
|
||||
"""Hashcat cmd should include -m, -a 3, --markov-hcstat2, --increment, mask."""
|
||||
calls = self._run_full(markov_env)
|
||||
assert len(calls) >= 2, "Expected at least hcstat2gen + hashcat Popen calls"
|
||||
|
||||
hashcat_cmd = calls[1][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"
|
||||
assert "-a" in hashcat_cmd
|
||||
a_idx = hashcat_cmd.index("-a")
|
||||
assert hashcat_cmd[a_idx + 1] == "3"
|
||||
# Check markov hcstat2 flag
|
||||
hcstat2_flags = [f for f in hashcat_cmd if "--markov-hcstat2=" in f]
|
||||
assert len(hcstat2_flags) == 1
|
||||
assert hcstat2_flags[0].endswith(".hcstat2")
|
||||
assert "--increment" in hashcat_cmd
|
||||
# Mask should be the last positional arg
|
||||
assert "?a?a?a?a?a?a?a?a?a?a?a?a?a?a" in hashcat_cmd
|
||||
|
||||
def test_hashcat_includes_tuning(self, markov_env):
|
||||
"""-w 3 tuning flag should be appended to hashcat cmd."""
|
||||
calls = self._run_full(markov_env, tuning="-w 3")
|
||||
hashcat_cmd = calls[1][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, markov_env):
|
||||
"""--potfile-path should be present when hcatPotfilePath is set."""
|
||||
calls = self._run_full(markov_env, potfile="/tmp/test.potfile")
|
||||
hashcat_cmd = calls[1][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, markov_env, capsys):
|
||||
"""KeyboardInterrupt during hashcat should kill the process."""
|
||||
hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw"
|
||||
call_count = [0]
|
||||
|
||||
def track_popen(cmd, **kwargs):
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
# hcstat2gen: succeeds and creates raw file
|
||||
proc = _make_proc()
|
||||
proc.pid = 99999
|
||||
with open(hcstat2_raw_path, "wb") as f:
|
||||
f.write(b"\x00" * 100)
|
||||
else:
|
||||
# hashcat: KeyboardInterrupt
|
||||
proc = mock.MagicMock()
|
||||
proc.pid = 99999
|
||||
proc.wait.side_effect = KeyboardInterrupt()
|
||||
return proc
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
_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.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Killing PID" in captured.out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Step F: Cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestHcatMarkovCleanup:
|
||||
"""Test that temp files are removed after a successful run."""
|
||||
class TestHcatOllamaWordlistPersistence:
|
||||
"""Test that the generated wordlist file persists after the run."""
|
||||
|
||||
def _run_full(self, markov_env):
|
||||
"""Run hcatMarkov through all steps successfully."""
|
||||
hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw"
|
||||
|
||||
def track_popen(cmd, **kwargs):
|
||||
proc = _make_proc()
|
||||
if cmd[0] == markov_env.hcstat2gen:
|
||||
with open(hcstat2_raw_path, "wb") as f:
|
||||
f.write(b"\x00" * 100)
|
||||
return proc
|
||||
|
||||
with markov_globals(markov_env.tmp_path), \
|
||||
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", side_effect=track_popen), \
|
||||
mock.patch("subprocess.Popen", return_value=_make_proc()), \
|
||||
mock.patch("hate_crack.main.generate_session_id", return_value="test_session"):
|
||||
hc_main.hcatMarkov(
|
||||
"0", markov_env.hash_file, "wordlist", markov_env.wordlist, 10,
|
||||
hc_main.hcatOllama(
|
||||
"0", ollama_env.hash_file, "wordlist", ollama_env.wordlist,
|
||||
)
|
||||
|
||||
def test_temp_files_removed(self, markov_env):
|
||||
"""markov_candidates and hcstat2_raw should be deleted; hcstat2 should remain."""
|
||||
self._run_full(markov_env)
|
||||
|
||||
candidates_path = f"{markov_env.hash_file}.markov_candidates"
|
||||
hcstat2_raw_path = f"{markov_env.hash_file}.hcstat2_raw"
|
||||
hcstat2_path = f"{markov_env.hash_file}.hcstat2"
|
||||
|
||||
assert not os.path.exists(candidates_path), "candidates temp file should be removed"
|
||||
assert not os.path.exists(hcstat2_raw_path), "raw hcstat2 temp file should be removed"
|
||||
assert os.path.isfile(hcstat2_path), "compressed hcstat2 file should remain"
|
||||
|
||||
def test_cleanup_ignores_missing_files(self, markov_env):
|
||||
"""No exception if temp files are already gone before cleanup."""
|
||||
# This just verifies the run completes without error even though
|
||||
# we don't interfere with normal cleanup
|
||||
self._run_full(markov_env) # should not raise
|
||||
candidates_path = f"{ollama_env.hash_file}.ollama_candidates"
|
||||
assert os.path.isfile(candidates_path), "candidates wordlist should persist"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# attacks.py: markov_attack() UI handler
|
||||
# attacks.py: ollama_attack() UI handler
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMarkovAttackHandler:
|
||||
"""Test the markov_attack(ctx) menu handler in attacks.py."""
|
||||
class TestOllamaAttackHandler:
|
||||
"""Test the ollama_attack(ctx) menu handler in attacks.py."""
|
||||
|
||||
def _make_ctx(self, **overrides):
|
||||
"""Build a mock ctx with the attributes markov_attack() reads."""
|
||||
"""Build a mock ctx with the attributes ollama_attack() reads."""
|
||||
ctx = mock.MagicMock()
|
||||
ctx.hcatHashType = "0"
|
||||
ctx.hcatHashFile = "/tmp/hashes.txt"
|
||||
ctx.markovWordlist = "/tmp/wordlist.txt"
|
||||
ctx.markovCandidateCount = 5000
|
||||
ctx.ollamaWordlist = "/tmp/wordlist.txt"
|
||||
ctx.ollamaCandidateCount = 5000
|
||||
ctx.hcatWordlists = "/tmp/wordlists"
|
||||
for k, v in overrides.items():
|
||||
setattr(ctx, k, v)
|
||||
return ctx
|
||||
|
||||
def test_wordlist_mode_default(self, tmp_path):
|
||||
"""When cracked output exists, it becomes the default wordlist."""
|
||||
from hate_crack.attacks import markov_attack
|
||||
"""Default wordlist is always ollamaWordlist from config."""
|
||||
from hate_crack.attacks import ollama_attack
|
||||
|
||||
hash_file = str(tmp_path / "hashes.txt")
|
||||
cracked_out = hash_file + ".out"
|
||||
@@ -826,51 +671,51 @@ class TestMarkovAttackHandler:
|
||||
|
||||
ctx = self._make_ctx(hcatHashFile=hash_file)
|
||||
with mock.patch("builtins.input", side_effect=["1", "n", ""]):
|
||||
markov_attack(ctx)
|
||||
ollama_attack(ctx)
|
||||
|
||||
ctx.hcatMarkov.assert_called_once_with(
|
||||
"0", hash_file, "wordlist", cracked_out, 5000,
|
||||
ctx.hcatOllama.assert_called_once_with(
|
||||
"0", hash_file, "wordlist", "/tmp/wordlist.txt",
|
||||
)
|
||||
|
||||
def test_wordlist_mode_falls_back_to_config(self):
|
||||
"""When cracked output does not exist, fall back to markovWordlist."""
|
||||
from hate_crack.attacks import markov_attack
|
||||
"""When cracked output does not exist, fall back to ollamaWordlist."""
|
||||
from hate_crack.attacks import ollama_attack
|
||||
|
||||
ctx = self._make_ctx(hcatHashFile="/tmp/nonexistent_hashes.txt")
|
||||
with mock.patch("builtins.input", side_effect=["1", "n", ""]):
|
||||
markov_attack(ctx)
|
||||
ollama_attack(ctx)
|
||||
|
||||
ctx.hcatMarkov.assert_called_once_with(
|
||||
"0", "/tmp/nonexistent_hashes.txt", "wordlist", "/tmp/wordlist.txt", 5000,
|
||||
ctx.hcatOllama.assert_called_once_with(
|
||||
"0", "/tmp/nonexistent_hashes.txt", "wordlist", "/tmp/wordlist.txt",
|
||||
)
|
||||
|
||||
def test_wordlist_mode_custom_path(self):
|
||||
"""Selection '1', override wordlist → resolved path passed to hcatMarkov."""
|
||||
from hate_crack.attacks import markov_attack
|
||||
"""Selection '1', override wordlist → resolved path passed to hcatOllama."""
|
||||
from hate_crack.attacks import ollama_attack
|
||||
|
||||
ctx = self._make_ctx()
|
||||
ctx.select_file_with_autocomplete.return_value = "custom.txt"
|
||||
ctx._resolve_wordlist_path.return_value = "/resolved/custom.txt"
|
||||
|
||||
with mock.patch("builtins.input", side_effect=["1", "y", ""]):
|
||||
markov_attack(ctx)
|
||||
ollama_attack(ctx)
|
||||
|
||||
ctx.select_file_with_autocomplete.assert_called_once()
|
||||
ctx._resolve_wordlist_path.assert_called_once_with("custom.txt", "/tmp/wordlists")
|
||||
ctx.hcatMarkov.assert_called_once_with(
|
||||
"0", "/tmp/hashes.txt", "wordlist", "/resolved/custom.txt", 5000,
|
||||
ctx.hcatOllama.assert_called_once_with(
|
||||
"0", "/tmp/hashes.txt", "wordlist", "/resolved/custom.txt",
|
||||
)
|
||||
|
||||
def test_target_mode(self):
|
||||
"""Selection '2' → hcatMarkov('target', {company, industry, location})."""
|
||||
from hate_crack.attacks import markov_attack
|
||||
"""Selection '2' → hcatOllama('target', {company, industry, location})."""
|
||||
from hate_crack.attacks import ollama_attack
|
||||
|
||||
ctx = self._make_ctx()
|
||||
with mock.patch("builtins.input", side_effect=["2", "AcmeCorp", "Finance", "NYC", ""]):
|
||||
markov_attack(ctx)
|
||||
ollama_attack(ctx)
|
||||
|
||||
ctx.hcatMarkov.assert_called_once()
|
||||
call_args = ctx.hcatMarkov.call_args
|
||||
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"
|
||||
@@ -878,28 +723,28 @@ class TestMarkovAttackHandler:
|
||||
assert target_info["location"] == "NYC"
|
||||
|
||||
def test_invalid_selection(self, capsys):
|
||||
"""Selection '3' → 'Invalid selection.', no hcatMarkov call."""
|
||||
from hate_crack.attacks import markov_attack
|
||||
"""Selection '3' → 'Invalid selection.', no hcatOllama call."""
|
||||
from hate_crack.attacks import ollama_attack
|
||||
|
||||
ctx = self._make_ctx()
|
||||
with mock.patch("builtins.input", side_effect=["3"]):
|
||||
markov_attack(ctx)
|
||||
ollama_attack(ctx)
|
||||
|
||||
captured = capsys.readouterr()
|
||||
assert "Invalid selection" in captured.out
|
||||
ctx.hcatMarkov.assert_not_called()
|
||||
ctx.hcatOllama.assert_not_called()
|
||||
|
||||
def test_wordlist_list_uses_first(self):
|
||||
"""When markovWordlist is a list and no cracked output exists, the first element is used."""
|
||||
from hate_crack.attacks import markov_attack
|
||||
"""When ollamaWordlist is a list and no cracked output exists, the first element is used."""
|
||||
from hate_crack.attacks import ollama_attack
|
||||
|
||||
ctx = self._make_ctx(
|
||||
hcatHashFile="/tmp/nonexistent_hashes.txt",
|
||||
markovWordlist=["/tmp/first.txt", "/tmp/second.txt"],
|
||||
ollamaWordlist=["/tmp/first.txt", "/tmp/second.txt"],
|
||||
)
|
||||
with mock.patch("builtins.input", side_effect=["1", "n", ""]):
|
||||
markov_attack(ctx)
|
||||
ollama_attack(ctx)
|
||||
|
||||
ctx.hcatMarkov.assert_called_once()
|
||||
call_args = ctx.hcatMarkov.call_args
|
||||
ctx.hcatOllama.assert_called_once()
|
||||
call_args = ctx.hcatOllama.call_args
|
||||
assert call_args[0][3] == "/tmp/first.txt"
|
||||
|
||||
Reference in New Issue
Block a user