From 62a83699aca17b9c1aa3574c48324728880f3ad6 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Wed, 1 Jul 2026 16:30:38 -0400 Subject: [PATCH 01/51] test: fix hate_path leak causing flaky test_main_pcfg hate_crack.main is imported once and shared for the whole pytest session, so any test that mutates its module globals without restoring them leaks into every later test. test_markov_e2e assigned main.hate_path / main.hcatHcstat2genBin directly (and before its skip check, so it leaked even when skipped). test_main_pcfg's test_builds_expected_subprocess reads the ambient hate_path to locate pcfg_guesser.py and bails early when it's missing, so a leaked value made it intermittently fail at captured_calls[0] (IndexError). - test_markov_e2e: use monkeypatch.setattr so the globals auto-restore. - test_main_pcfg: pin hate_path to a tmp dir containing a stub pcfg_guesser.py, making the test hermetic and independent of both the real pcfg_cracker submodule checkout and any leaked global state. Full suite passes 3x consecutively; previously flaky. Co-Authored-By: Claude Opus 4.8 --- tests/test_main_pcfg.py | 10 ++++++++++ tests/test_markov_e2e.py | 26 ++++++++++++++++---------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/tests/test_main_pcfg.py b/tests/test_main_pcfg.py index 15eb7a3..c71112e 100644 --- a/tests/test_main_pcfg.py +++ b/tests/test_main_pcfg.py @@ -17,6 +17,15 @@ class TestHcatPCFG: hash_file = str(tmp_path / "hashes.txt") Path(hash_file).write_text("dummy") + # hcatPCFG resolves pcfg_guesser.py under the module global hate_path + # and bails early if it's missing. Pin hate_path to a tmp dir with a + # stub script so the test is hermetic — independent of the real + # pcfg_cracker submodule being checked out and of any hate_path value + # leaked by an earlier test (hate_crack.main is shared session-wide). + pcfg_dir = tmp_path / "pcfg_cracker" + pcfg_dir.mkdir() + (pcfg_dir / "pcfg_guesser.py").write_text("# stub") + captured_calls = [] class FakeProc: @@ -27,6 +36,7 @@ class TestHcatPCFG: with patch("hate_crack.main.subprocess.Popen", side_effect=FakeProc), \ patch("hate_crack.main._run_hcat_cmd") as mock_run, \ + patch.object(main_module, "hate_path", str(tmp_path)), \ patch.object(main_module, "hcatBin", "hashcat"), \ patch.object(main_module, "hcatTuning", ""), \ patch.object(main_module, "hcatPotfilePath", ""), \ diff --git a/tests/test_markov_e2e.py b/tests/test_markov_e2e.py index a302876..853ebda 100644 --- a/tests/test_markov_e2e.py +++ b/tests/test_markov_e2e.py @@ -10,14 +10,18 @@ import pytest class TestMarkovE2E: """End-to-end tests for complete markov attack workflow.""" - def test_markov_training_plain_text(self, tmp_path: Path) -> None: + def test_markov_training_plain_text(self, tmp_path: Path, monkeypatch) -> None: """Test markov training with plain text wordlist.""" from hate_crack import main - # Setup paths - main.hate_path = Path(__file__).resolve().parents[1] - main.hcatHcstat2genBin = "hcstat2gen.bin" - bin_path = main.hate_path / "hashcat-utils" / "bin" / "hcstat2gen.bin" + # Setup paths. Use monkeypatch so these module globals are restored + # after the test — hate_crack.main is imported once and shared across + # the whole session, so a raw assignment here would leak into every + # later test (e.g. test_main_pcfg, which reads the ambient hate_path). + repo_root = Path(__file__).resolve().parents[1] + monkeypatch.setattr(main, "hate_path", repo_root) + monkeypatch.setattr(main, "hcatHcstat2genBin", "hcstat2gen.bin") + bin_path = repo_root / "hashcat-utils" / "bin" / "hcstat2gen.bin" if not bin_path.is_file(): pytest.skip(f"hcstat2gen.bin not compiled: {bin_path}") @@ -39,14 +43,16 @@ class TestMarkovE2E: assert hcstat2_path.exists(), ".hcstat2 file should be created" assert hcstat2_path.stat().st_size > 0, ".hcstat2 file should not be empty" - def test_markov_training_gzipped(self, tmp_path: Path) -> None: + def test_markov_training_gzipped(self, tmp_path: Path, monkeypatch) -> None: """Test markov training with gzipped wordlist.""" from hate_crack import main - # Setup paths - main.hate_path = Path(__file__).resolve().parents[1] - main.hcatHcstat2genBin = "hcstat2gen.bin" - bin_path = main.hate_path / "hashcat-utils" / "bin" / "hcstat2gen.bin" + # Setup paths (monkeypatch so these session-shared module globals are + # restored after the test — see test_markov_training_plain_text). + repo_root = Path(__file__).resolve().parents[1] + monkeypatch.setattr(main, "hate_path", repo_root) + monkeypatch.setattr(main, "hcatHcstat2genBin", "hcstat2gen.bin") + bin_path = repo_root / "hashcat-utils" / "bin" / "hcstat2gen.bin" if not bin_path.is_file(): pytest.skip(f"hcstat2gen.bin not compiled: {bin_path}") From 66e6d593c46bde4a7a133277baf3cb1c684d77d4 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 11:45:24 -0400 Subject: [PATCH 02/51] build(deps): add atomic-agents for structured LLM candidate generation --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..106fdeb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "packaging>=21.0", "simple-term-menu==1.6.6", "click>=8.0.0", + "atomic-agents>=2.0.0", ] [project.scripts] From 1cc71abfef006aeab1b5f258fa462f1a74c9dd9a Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 11:48:19 -0400 Subject: [PATCH 03/51] feat(llm): structured candidate generation module via Atomic Agents Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/llm.py | 134 ++++++++++++++++++++++++++++++++++++++++++++++ tests/test_llm.py | 100 ++++++++++++++++++++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 hate_crack/llm.py create mode 100644 tests/test_llm.py diff --git a/hate_crack/llm.py b/hate_crack/llm.py new file mode 100644 index 0000000..195e5d8 --- /dev/null +++ b/hate_crack/llm.py @@ -0,0 +1,134 @@ +"""Structured LLM password-candidate generation via Atomic Agents + Ollama. + +Isolates the atomic-agents / instructor dependency. The rest of hate_crack talks +to this module only through ``generate_candidates``. +""" + +from typing import Any + +import instructor +from openai import OpenAI +from pydantic import Field + +from atomic_agents import AgentConfig, AtomicAgent, BaseIOSchema +from atomic_agents.context import SystemPromptGenerator + +MAX_CANDIDATE_LEN = 128 + + +class GenerationInput(BaseIOSchema): + """Instruction and context for a password-candidate generation request.""" + + request: str = Field( + ..., description="The full instruction, including any target or sample context." + ) + + +class PasswordCandidatesOutput(BaseIOSchema): + """A structured list of candidate passwords / basewords.""" + + candidates: list[str] = Field( + ..., + description=( + "Candidate passwords, one per list entry. No numbering, bullets, or " + "explanation — just the raw candidate strings." + ), + ) + + +_TARGET_PROMPT = SystemPromptGenerator( + background=[ + "You are a security professional generating password candidates during an " + "authorized penetration test / capture-the-flag exercise.", + ], + steps=[ + "Study the provided target context (company, industry, location).", + "Derive basewords from the company name and industry terms.", + "Combine basewords with common suffixes, years, and leetspeak substitutions.", + ], + output_instructions=[ + "Return only candidate passwords in the candidates list.", + "Do not include explanations, numbering, or duplicate entries.", + ], +) + +_WORDLIST_PROMPT = SystemPromptGenerator( + background=[ + "You build denylist basewords so users cannot set weak passwords.", + ], + steps=[ + "Study the sample passwords for patterns: capitalization, leetspeak, " + "suffixes, and common substitutions.", + "Produce basewords that capture those patterns.", + ], + output_instructions=[ + "Return only basewords in the candidates list.", + "Do not include explanations, numbering, or duplicate entries.", + ], +) + + +def _build_request(mode: str, context_data: dict) -> str: + """Build the natural-language request string for the given mode.""" + if mode == "target": + company = context_data.get("company", "") + industry = context_data.get("industry", "") + location = context_data.get("location", "") + return ( + f"The target organization is '{company}', a {industry} in {location}. " + "Generate as many plausible password candidates as you can, using " + "permutations of the company name and industry terms with common " + "suffixes, years, and leetspeak substitutions." + ) + if mode == "wordlist": + sample = context_data.get("sample", "") + return ( + "Here are sample passwords. Study their patterns and generate basewords " + "for a denylist:\n" + sample + ) + raise ValueError(f"Unknown LLM generation mode: {mode}") + + +def generate_candidates( + url: str, + model: str, + num_ctx: int, + mode: str, + context_data: dict, +) -> list[str]: + """Generate password candidates via an Ollama-backed AtomicAgent. + + Returns a deduped, length-capped list of candidate strings (may be empty). + Raises ValueError for an unknown mode. Client/connection errors propagate to + the caller. + """ + request = _build_request(mode, context_data) + + client = instructor.from_openai( + OpenAI(base_url=f"{url}/v1", api_key="ollama"), + mode=instructor.Mode.JSON, + ) + prompt_generator = _TARGET_PROMPT if mode == "target" else _WORDLIST_PROMPT + + agent = AtomicAgent[GenerationInput, PasswordCandidatesOutput]( + config=AgentConfig.model_construct( + client=client, + model=model, + system_prompt_generator=prompt_generator, + model_api_parameters={"extra_body": {"options": {"num_ctx": num_ctx}}}, + ) + ) + + result: Any = agent.run(GenerationInput(request=request)) + + seen: set[str] = set() + candidates: list[str] = [] + for raw in getattr(result, "candidates", []) or []: + candidate = str(raw).strip() + if not candidate or len(candidate) > MAX_CANDIDATE_LEN: + continue + if candidate in seen: + continue + seen.add(candidate) + candidates.append(candidate) + return candidates diff --git a/tests/test_llm.py b/tests/test_llm.py new file mode 100644 index 0000000..f96b39c --- /dev/null +++ b/tests/test_llm.py @@ -0,0 +1,100 @@ +"""Unit tests for hate_crack.llm.generate_candidates.""" + +import os +from unittest import mock + +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import llm # noqa: E402 + + +def _patch_agent(candidates): + """Patch the client builders + AtomicAgent so no network happens. + + Returns the AtomicAgent class mock so callers can inspect construction. + """ + result = mock.MagicMock() + result.candidates = list(candidates) + + agent_instance = mock.MagicMock() + agent_instance.run.return_value = result + + agent_cls = mock.MagicMock() + # AtomicAgent[In, Out](config=...) -> agent_instance + agent_cls.__getitem__.return_value.return_value = agent_instance + + return ( + mock.patch("hate_crack.llm.instructor"), + mock.patch("hate_crack.llm.OpenAI"), + mock.patch("hate_crack.llm.AtomicAgent", agent_cls), + agent_cls, + agent_instance, + ) + + +def test_target_mode_returns_candidates(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent( + ["AcmeCorp2024", "Finance123"] + ) + with p_instr, p_openai, p_agent: + out = llm.generate_candidates( + "http://localhost:11434", + "qwen2.5:32b", + 2048, + "target", + {"company": "AcmeCorp", "industry": "Finance", "location": "NYC"}, + ) + assert out == ["AcmeCorp2024", "Finance123"] + # The instruction the agent received must include the target context. + run_arg = agent_instance.run.call_args[0][0] + assert "AcmeCorp" in run_arg.request + assert "Finance" in run_arg.request + assert "NYC" in run_arg.request + + +def test_wordlist_mode_includes_sample_in_request(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["Passw0rd"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", + "qwen2.5:32b", + 2048, + "wordlist", + {"sample": "password\nletmein\nsummer2024"}, + ) + run_arg = agent_instance.run.call_args[0][0] + assert "letmein" in run_arg.request + + +def test_dedupes_and_caps_length(): + long_pw = "A" * 129 + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent( + [" keep ", "keep", "dup", "dup", long_pw, ""] + ) + with p_instr, p_openai, p_agent: + out = llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + ) + assert out == ["keep", "dup"] # trimmed, deduped, blank + >128 dropped + + +def test_num_ctx_forwarded_via_model_api_parameters(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 4096, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + ) + # AtomicAgent[In, Out](config=) — inspect the config. + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.model == "qwen2.5:32b" + assert config.model_api_parameters["extra_body"]["options"]["num_ctx"] == 4096 + + +def test_unknown_mode_raises(): + with pytest.raises(ValueError): + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, "bogus", {}, + ) From a57a4165f1063bb9806308726697f6445aaa5949 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 11:50:19 -0400 Subject: [PATCH 04/51] refactor(llm): validate AgentConfig in prod; spec-mock the client in tests Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/llm.py | 2 +- tests/test_llm.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/hate_crack/llm.py b/hate_crack/llm.py index 195e5d8..44ad687 100644 --- a/hate_crack/llm.py +++ b/hate_crack/llm.py @@ -111,7 +111,7 @@ def generate_candidates( prompt_generator = _TARGET_PROMPT if mode == "target" else _WORDLIST_PROMPT agent = AtomicAgent[GenerationInput, PasswordCandidatesOutput]( - config=AgentConfig.model_construct( + config=AgentConfig( client=client, model=model, system_prompt_generator=prompt_generator, diff --git a/tests/test_llm.py b/tests/test_llm.py index f96b39c..f555bfc 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -3,6 +3,7 @@ import os from unittest import mock +import instructor import pytest os.environ["HATE_CRACK_SKIP_INIT"] = "1" @@ -25,7 +26,7 @@ def _patch_agent(candidates): agent_cls.__getitem__.return_value.return_value = agent_instance return ( - mock.patch("hate_crack.llm.instructor"), + mock.patch("hate_crack.llm.instructor.from_openai", return_value=mock.MagicMock(spec=instructor.Instructor)), mock.patch("hate_crack.llm.OpenAI"), mock.patch("hate_crack.llm.AtomicAgent", agent_cls), agent_cls, From 9dd6ad746f612f91f119ebcc8c3f5b76ffea9f20 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 11:52:32 -0400 Subject: [PATCH 05/51] style(llm): drop noise Any annotation on agent result Co-Authored-By: Claude Opus 4.8 --- hate_crack/llm.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/hate_crack/llm.py b/hate_crack/llm.py index 44ad687..89ecd88 100644 --- a/hate_crack/llm.py +++ b/hate_crack/llm.py @@ -4,8 +4,6 @@ Isolates the atomic-agents / instructor dependency. The rest of hate_crack talks to this module only through ``generate_candidates``. """ -from typing import Any - import instructor from openai import OpenAI from pydantic import Field @@ -119,7 +117,7 @@ def generate_candidates( ) ) - result: Any = agent.run(GenerationInput(request=request)) + result = agent.run(GenerationInput(request=request)) seen: set[str] = set() candidates: list[str] = [] From 4df11210513cb9d03b16c7256e4cc6972fb81874 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 11:59:04 -0400 Subject: [PATCH 06/51] refactor(llm): delegate candidate generation to llm module; drop auto-pull Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/main.py | 151 +------ tests/test_hcat_ollama.py | 141 +++++++ tests/test_pull_ollama_model.py | 684 -------------------------------- 3 files changed, 156 insertions(+), 820 deletions(-) create mode 100644 tests/test_hcat_ollama.py delete mode 100644 tests/test_pull_ollama_model.py diff --git a/hate_crack/main.py b/hate_crack/main.py index 7734aec..231b442 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -21,8 +21,6 @@ import subprocess import shlex import time import argparse -import urllib.request -import urllib.error import contextlib import gzip import lzma @@ -75,6 +73,7 @@ from hate_crack.cli import ( # noqa: E402 setup_logging, ) from hate_crack import attacks as _attacks # noqa: E402 +from hate_crack import llm # noqa: E402 from hate_crack.menu import interactive_menu # noqa: E402 from hate_crack.username_detect import detect_username_hash_format # noqa: E402 @@ -2036,49 +2035,12 @@ def hcatBandrel(hcatHashType, hcatHashFile): _run_hcat_cmd(cmd, attack_name="Bandrel", hash_file=hcatHashFile) -# Pull an Ollama model via the /api/pull streaming endpoint -def _pull_ollama_model(url, model): - """Pull an Ollama model. Returns True on success, False on failure.""" - print(f"Model '{model}' not found locally. Pulling from Ollama...") - pull_url = f"{url}/api/pull" - payload = json.dumps({"name": model, "stream": True}).encode("utf-8") - req = urllib.request.Request( - pull_url, - data=payload, - headers={"Content-Type": "application/json"}, - ) - try: - with urllib.request.urlopen(req) as resp: - for raw_line in resp: - line = raw_line.decode("utf-8", errors="replace").strip() - if not line: - continue - try: - data = json.loads(line) - except json.JSONDecodeError: - continue - status = data.get("status") - if status: - print(f" {status}") - except urllib.error.HTTPError as e: - print(f"Error pulling model: HTTP {e.code}") - return False - except urllib.error.URLError as e: - print(f"Error: Could not connect to Ollama: {e}") - return False - except Exception as e: - print(f"Error pulling model: {e}") - return False - print(f"Successfully pulled model '{model}'.") - return True - - # LLM Ollama Attack def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): global hcatProcess candidates_path = f"{hcatHashFile}.ollama_candidates" - # Step A: Build LLM prompt based on mode + # Step A: normalize context into the dict generate_candidates expects. if mode == "wordlist": wordlist_path = context_data if not os.path.isfile(wordlist_path): @@ -2091,7 +2053,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): stripped = line.strip() if not stripped: continue - # Use only content after the first colon (e.g. hash:password -> password) + # hash:password -> password if ":" in stripped: stripped = stripped.split(":", 1)[1] if stripped: @@ -2100,107 +2062,29 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): print(f"Error reading wordlist: {e}") return print(f"Loaded {len(lines)} passwords from wordlist.") - wordlist_sample = "\n".join(lines) - prompt = ( - "Generate baseword to be used in a denylist for keeping users from setting their passwords with these basewords." - "Study the patterns, character choices, and structures. Focus on patterns like capitalization, leetspeak, suffixes, and common substitutions. Here are the sample passwords:\n" - f"{wordlist_sample}" - ) + gen_context = {"sample": "\n".join(lines)} elif mode == "target": - company = context_data.get("company", "") - industry = context_data.get("industry", "") - location = context_data.get("location", "") - prompt = ( - "You are participating in a capture the flag event as a security professional. " - "You are my partner in the competition. You need to recover the password to a system to retrieve the flag. " - "Output as many possible password combinations you think might help us. " - f"The name of the fake company is {company}. They are a {industry} in {location}. " - "Use terms related to the industry as basewords and also use permutations of the company name combined with common suffixes. " - "Only output the candidate password each on a new line. Dont output any explanation. " - "Only output the password candidate. Do not number the lines or add any extra information to the output" - ) + gen_context = context_data else: print(f"Error: Unknown LLM generation mode: {mode}") return - # Step B: Call Ollama API to generate candidates + # Step B: generate candidates via the Atomic Agents module. print(f"Generating password candidates via Ollama ({ollamaModel})...") - api_url = f"{ollamaUrl}/api/generate" - payload = json.dumps( - { - "model": ollamaModel, - "prompt": prompt, - "stream": False, - "options": {"num_ctx": ollamaNumCtx}, - } - ).encode("utf-8") - - if debug_mode: - print(f"[DEBUG] Ollama API URL: {api_url}") - print(f"[DEBUG] Ollama request payload: {payload.decode('utf-8')}") - try: - req = urllib.request.Request( - api_url, - data=payload, - headers={"Content-Type": "application/json"}, + candidates = llm.generate_candidates( + ollamaUrl, ollamaModel, ollamaNumCtx, mode, gen_context ) - with urllib.request.urlopen(req, timeout=600) as resp: - result = json.loads(resp.read().decode("utf-8")) - if debug_mode: - print(f"[DEBUG] Ollama response: {json.dumps(result, indent=2)}") - except urllib.error.HTTPError as e: - if e.code == 404: - if _pull_ollama_model(ollamaUrl, ollamaModel): - try: - req = urllib.request.Request( - api_url, - data=payload, - headers={"Content-Type": "application/json"}, - ) - with urllib.request.urlopen(req, timeout=600) as resp: - result = json.loads(resp.read().decode("utf-8")) - if debug_mode: - print( - f"[DEBUG] Ollama response (after pull): {json.dumps(result, indent=2)}" - ) - except Exception as retry_err: - print(f"Error calling Ollama API after pull: {retry_err}") - return - else: - print(f"Could not pull model '{ollamaModel}'. Aborting LLM attack.") - return - else: - print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") - print("Ensure Ollama is running (ollama serve) and try again.") - return - except urllib.error.URLError as e: - print(f"Error: Could not connect to Ollama at {ollamaUrl}: {e}") - print("Ensure Ollama is running (ollama serve) and try again.") + except ValueError as e: + print(f"Error: {e}") return except Exception as e: - print(f"Error calling Ollama API: {e}") - return - - response_text = result.get("response", "") - if "I'm sorry, but I can't help with that" in response_text: + print(f"Error generating candidates: {e}") print( - "Error: Ollama refused the request. Try a different model or adjust your prompt." + "Ensure Ollama is running (ollama serve) and the model is pulled " + f"(ollama pull {ollamaModel})." ) return - raw_lines = response_text.strip().split("\n") - # Filter out blank lines and lines that look like numbering/explanation - candidates = [] - for line in raw_lines: - stripped = line.strip() - if not stripped: - continue - # Strip leading numbering like "1. " or "1) " or "- " - cleaned = re.sub(r"^\d+[.)]\s*", "", stripped) - cleaned = re.sub(r"^[-*]\s*", "", cleaned) - cleaned = cleaned.strip() - if cleaned and len(cleaned) <= 128: - candidates.append(cleaned) if not candidates: print("Error: Ollama returned no usable password candidates.") @@ -2215,13 +2099,8 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): return print(f"Generated {len(candidates)} password candidates -> {candidates_path}") - if debug_mode: - 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 hashcat wordlist attack with LLM-generated candidates (no rules) + # Step C: hashcat wordlist attack with the generated candidates (no rules). print("Running wordlist attack with LLM-generated candidates...") cmd = [ hcatBin, @@ -2246,7 +2125,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): except KeyboardInterrupt: return - # Step D: Run hashcat with LLM candidates against every rule in the rules directory + # Step D: hashcat with 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.") diff --git a/tests/test_hcat_ollama.py b/tests/test_hcat_ollama.py new file mode 100644 index 0000000..2da4d65 --- /dev/null +++ b/tests/test_hcat_ollama.py @@ -0,0 +1,141 @@ +"""Orchestration tests for hcatOllama (candidate generation is mocked).""" + +import os +from types import SimpleNamespace +from contextlib import contextmanager +from unittest import mock + +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import main as hc_main # noqa: E402 + +OLLAMA_URL = "http://localhost:11434" +MODEL = "qwen2.5:32b" + + +@pytest.fixture +def ollama_env(tmp_path): + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + wordlist = tmp_path / "sample.txt" + wordlist.write_text("password\n123456\nletmein\n") + return SimpleNamespace( + tmp_path=tmp_path, hash_file=str(hash_file), wordlist=str(wordlist) + ) + + +@contextmanager +def ollama_globals(tmp_path, tuning="", potfile=""): + rules_dir = str(tmp_path / "rules") + os.makedirs(rules_dir, exist_ok=True) + with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \ + mock.patch.object(hc_main, "ollamaModel", MODEL), \ + mock.patch.object(hc_main, "ollamaNumCtx", 2048), \ + 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, "rulesDirectory", rules_dir), \ + mock.patch("hate_crack.main.generate_session_id", return_value="s"): + yield + + +def _make_proc(wait_return=0): + proc = mock.MagicMock() + proc.wait.return_value = wait_return + proc.communicate.return_value = (b"", b"") + proc.returncode = wait_return + return proc + + +def test_pull_ollama_model_is_gone(): + assert not hasattr(hc_main, "_pull_ollama_model") + + +def test_target_mode_passes_dict_through(ollama_env): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama( + "0", ollama_env.hash_file, "target", + {"company": "ACME", "industry": "tech", "location": "NYC"}, + ) + gen.assert_called_once() + args = gen.call_args[0] + assert args[0] == OLLAMA_URL and args[1] == MODEL and args[3] == "target" + assert args[4] == {"company": "ACME", "industry": "tech", "location": "NYC"} + + +def test_wordlist_mode_reads_file_and_passes_sample(ollama_env): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", ollama_env.wordlist) + ctx_data = gen.call_args[0][4] + assert "letmein" in ctx_data["sample"] + + +def test_missing_wordlist_prints_error(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates") as gen: + hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", "/no/such.txt") + captured = capsys.readouterr() + assert "Wordlist not found" in captured.out + gen.assert_not_called() + + +def test_writes_candidates_and_runs_hashcat(ollama_env): + calls = [] + + def track_popen(cmd, **kwargs): + calls.append(list(cmd)) + return _make_proc() + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1", "Summer2024"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen): + hc_main.hcatOllama("1000", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + + candidates_path = f"{ollama_env.hash_file}.ollama_candidates" + assert os.path.isfile(candidates_path) + with open(candidates_path) as f: + assert f.read().splitlines() == ["Password1", "Summer2024"] + # First hashcat call is the plain wordlist run with the candidates file. + assert calls and candidates_path in calls[0] + assert "-r" not in calls[0] + + +def test_empty_candidates_skips_hashcat(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", return_value=[]), \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + captured = capsys.readouterr() + assert "no usable" in captured.out.lower() + popen.assert_not_called() + + +def test_generation_error_reports_and_aborts(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + side_effect=Exception("connection refused")), \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + captured = capsys.readouterr() + assert "Ensure Ollama is running" in captured.out + popen.assert_not_called() + + +def test_unknown_mode_prints_error(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates") as gen: + hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", {}) + captured = capsys.readouterr() + assert "Unknown LLM generation mode" in captured.out + gen.assert_not_called() diff --git a/tests/test_pull_ollama_model.py b/tests/test_pull_ollama_model.py deleted file mode 100644 index 54abe21..0000000 --- a/tests/test_pull_ollama_model.py +++ /dev/null @@ -1,684 +0,0 @@ -"""Unit tests for _pull_ollama_model helper, hcatOllama steps A-C, and ollama_attack handler.""" - -import io -import json -import os -import urllib.error -import urllib.request -from contextlib import contextmanager -from types import SimpleNamespace -from unittest import mock - -import pytest - -os.environ["HATE_CRACK_SKIP_INIT"] = "1" -from hate_crack import main as hc_main # noqa: E402 - - -# --------------------------------------------------------------------------- -# Shared test infrastructure -# --------------------------------------------------------------------------- - -OLLAMA_URL = "http://localhost:11434" -MODEL = "llama3.2" - - -@pytest.fixture -def ollama_env(tmp_path): - """Create the filesystem layout hcatOllama expects.""" - hash_file = tmp_path / "hashes.txt" - hash_file.touch() - - wordlist = tmp_path / "sample.txt" - wordlist.write_text("password\n123456\nletmein\n") - - return SimpleNamespace( - tmp_path=tmp_path, - hash_file=str(hash_file), - wordlist=str(wordlist), - ) - - -@contextmanager -def ollama_globals(tmp_path, tuning="", potfile=""): - """Patch the hc_main globals that hcatOllama reads.""" - rules_dir = str(tmp_path / "rules") - os.makedirs(rules_dir, exist_ok=True) - 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, "rulesDirectory", rules_dir), \ - mock.patch("hate_crack.main.generate_session_id", return_value="test_session"): - yield - - -def _make_proc(wait_return=0): - """Create a mock subprocess that works with both wait() and communicate().""" - proc = mock.MagicMock() - proc.wait.return_value = wait_return - proc.communicate.return_value = (b"", b"") - proc.returncode = wait_return - return proc - - -def _generate_response(passwords): - """Build a fake urlopen context-manager that returns an Ollama /api/generate JSON body.""" - body = json.dumps({"response": "\n".join(passwords)}).encode() - resp = mock.MagicMock() - resp.__enter__ = mock.Mock(return_value=io.BytesIO(body)) - resp.__exit__ = mock.Mock(return_value=False) - return resp - - -def _urlopen_with_response(passwords): - """Return a urlopen mock that always succeeds with the given passwords.""" - return mock.patch( - "hate_crack.main.urllib.request.urlopen", - return_value=_generate_response(passwords), - ) - - -# --------------------------------------------------------------------------- -# _pull_ollama_model tests -# --------------------------------------------------------------------------- - -class TestPullOllamaModel: - """Tests for _pull_ollama_model().""" - - OLLAMA_URL = "http://localhost:11434" - MODEL = "llama3.2" - - def _make_stream_response(self, statuses): - """Build a fake streaming response (newline-delimited JSON).""" - lines = [json.dumps({"status": s}).encode() + b"\n" for s in statuses] - return io.BytesIO(b"".join(lines)) - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_successful_pull(self, mock_urlopen, capsys): - mock_urlopen.return_value.__enter__ = mock.Mock( - return_value=self._make_stream_response( - ["pulling manifest", "downloading sha256:abc123", "success"] - ) - ) - mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is True - captured = capsys.readouterr() - assert "not found locally" in captured.out - assert "Successfully pulled" in captured.out - assert "pulling manifest" in captured.out - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_http_error(self, mock_urlopen, capsys): - mock_urlopen.side_effect = urllib.error.HTTPError( - url="http://localhost:11434/api/pull", - code=500, - msg="Internal Server Error", - hdrs=None, - fp=None, - ) - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is False - captured = capsys.readouterr() - assert "HTTP 500" in captured.out - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_url_error(self, mock_urlopen, capsys): - mock_urlopen.side_effect = urllib.error.URLError("Connection refused") - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is False - captured = capsys.readouterr() - assert "Could not connect" in captured.out - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_generic_exception(self, mock_urlopen, capsys): - mock_urlopen.side_effect = RuntimeError("unexpected") - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is False - captured = capsys.readouterr() - assert "unexpected" in captured.out - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_handles_empty_status_lines(self, mock_urlopen, capsys): - """Blank lines and missing status keys should not crash.""" - lines = b'{"status": "downloading"}\n\n{"other": "data"}\n' - mock_urlopen.return_value.__enter__ = mock.Mock( - return_value=io.BytesIO(lines) - ) - mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) - - result = hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - assert result is True - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_pull_request_payload(self, mock_urlopen): - """Verify the pull request sends the correct model name and stream=True.""" - mock_urlopen.return_value.__enter__ = mock.Mock( - return_value=self._make_stream_response(["success"]) - ) - mock_urlopen.return_value.__exit__ = mock.Mock(return_value=False) - - hc_main._pull_ollama_model(self.OLLAMA_URL, self.MODEL) - - call_args = mock_urlopen.call_args - req = call_args[0][0] - body = json.loads(req.data.decode("utf-8")) - assert body["name"] == self.MODEL - assert body["stream"] is True - assert req.full_url == f"{self.OLLAMA_URL}/api/pull" - - -# --------------------------------------------------------------------------- -# hcatOllama 404-retry integration tests -# --------------------------------------------------------------------------- - -class TestHcatOllama404Retry: - """Test that hcatOllama auto-pulls on 404 and retries the generate call.""" - - OLLAMA_URL = "http://localhost:11434" - MODEL = "llama3.2" - - def _generate_response(self, passwords): - body = json.dumps({"response": "\n".join(passwords)}).encode() - return io.BytesIO(body) - - def _setup_env(self, tmp_path): - """Create the files/dirs hcatOllama needs before it hits the API.""" - hash_file = str(tmp_path / "hashes.txt") - open(hash_file, "w").close() - - # Create a small wordlist for "wordlist" mode - wordlist = str(tmp_path / "sample.txt") - with open(wordlist, "w") as f: - f.write("password\n123456\nletmein\n") - - return hash_file, wordlist - - @mock.patch("hate_crack.main._pull_ollama_model") - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_404_triggers_pull_then_retries(self, mock_urlopen, mock_pull, capsys, tmp_path): - """A 404 on generate should trigger a pull, then retry successfully.""" - hash_file, wordlist = self._setup_env(tmp_path) - - # First call: 404, second call: success - generate_ok = mock.MagicMock() - generate_ok.__enter__ = mock.Mock( - return_value=self._generate_response(["Password1", "Summer2024"]) - ) - generate_ok.__exit__ = mock.Mock(return_value=False) - - mock_urlopen.side_effect = [ - urllib.error.HTTPError( - url=f"{self.OLLAMA_URL}/api/generate", - code=404, - msg="Not Found", - hdrs=None, - fp=None, - ), - generate_ok, - ] - mock_pull.return_value = True - - rules_dir = str(tmp_path / "rules") - os.makedirs(rules_dir, exist_ok=True) - with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ - mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ - mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ - mock.patch.object(hc_main, "hcatTuning", ""), \ - mock.patch.object(hc_main, "hcatPotfilePath", ""), \ - mock.patch.object(hc_main, "hate_path", str(tmp_path)), \ - mock.patch.object(hc_main, "rulesDirectory", rules_dir), \ - 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.hcatOllama("0", hash_file, "wordlist", wordlist) - - mock_pull.assert_called_once_with(self.OLLAMA_URL, self.MODEL) - # urlopen called twice: first 404, then retry - assert mock_urlopen.call_count == 2 - - @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, hcatOllama should abort gracefully.""" - hash_file, wordlist = self._setup_env(tmp_path) - - mock_urlopen.side_effect = urllib.error.HTTPError( - url=f"{self.OLLAMA_URL}/api/generate", - code=404, - msg="Not Found", - hdrs=None, - fp=None, - ) - mock_pull.return_value = False - - with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ - mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ - mock.patch.object(hc_main, "hate_path", str(tmp_path)): - - hc_main.hcatOllama("0", hash_file, "wordlist", wordlist) - - captured = capsys.readouterr() - assert "Could not pull model" in captured.out - mock_pull.assert_called_once() - - @mock.patch("hate_crack.main.urllib.request.urlopen") - def test_non_404_http_error_propagates(self, mock_urlopen, capsys, tmp_path): - """Non-404 HTTP errors should not trigger a pull attempt.""" - hash_file, wordlist = self._setup_env(tmp_path) - - mock_urlopen.side_effect = urllib.error.HTTPError( - url=f"{self.OLLAMA_URL}/api/generate", - code=500, - msg="Internal Server Error", - hdrs=None, - fp=None, - ) - - with mock.patch.object(hc_main, "ollamaUrl", self.OLLAMA_URL), \ - mock.patch.object(hc_main, "ollamaModel", self.MODEL), \ - mock.patch.object(hc_main, "hate_path", str(tmp_path)): - - hc_main.hcatOllama("0", hash_file, "wordlist", wordlist) - - captured = capsys.readouterr() - # HTTPError is a subclass of URLError, so it hits the URLError handler - assert "Could not connect to Ollama" in captured.out or "Error calling Ollama API" in captured.out - assert "500" in captured.out - - -# --------------------------------------------------------------------------- -# Step A: Mode routing, prompt construction, early-return error paths -# --------------------------------------------------------------------------- - -class TestHcatOllamaModeRouting: - """Test mode selection, prompt building, and early-return errors.""" - - def test_unknown_mode_prints_error(self, ollama_env, capsys): - """Bad mode string → error message, no API call.""" - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: - hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", "") - - captured = capsys.readouterr() - assert "Unknown LLM generation mode" in captured.out - mock_url.assert_not_called() - - def test_missing_wordlist_prints_error(self, ollama_env, capsys): - """Non-existent wordlist path → error, no API call.""" - with ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen") as mock_url: - 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_wordlist_mode_reads_all_lines(self, ollama_env, capsys): - """All non-blank lines from the wordlist should appear in the prompt.""" - # Write 600 lines to the wordlist - 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 = {} - - def fake_urlopen(req, **kwargs): - captured_payload["data"] = json.loads(req.data.decode()) - return _generate_response(["Password1"]) - - 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.hcatOllama( - "0", ollama_env.hash_file, "wordlist", str(big_wordlist), - ) - - prompt_text = captured_payload["data"]["prompt"] - # All lines should be included in the prompt - assert "pass0" in prompt_text - assert "pass499" in prompt_text - assert "pass599" in prompt_text - - def test_target_mode_includes_context_in_prompt(self, ollama_env, capsys): - """Company, industry, and location should appear in the prompt.""" - captured_payload = {} - - def fake_urlopen(req, **kwargs): - captured_payload["data"] = json.loads(req.data.decode()) - return _generate_response(["AcmeCorp2024"]) - - target_info = { - "company": "AcmeCorp", - "industry": "Finance", - "location": "New York", - } - 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.hcatOllama( - "0", ollama_env.hash_file, "target", target_info, - ) - - prompt_text = captured_payload["data"]["prompt"] - assert "AcmeCorp" in prompt_text - assert "Finance" in prompt_text - assert "New York" in prompt_text - - -# --------------------------------------------------------------------------- -# Step B: Candidate filtering / post-processing -# --------------------------------------------------------------------------- - -class TestHcatOllamaCandidateFiltering: - """Test regex stripping, blank-line removal, 128-char limit, empty-result handling.""" - - 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) - - 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", side_effect=capture_popen): - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - return captured.get("content") - - 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 - assert "Summer2024" in lines - # No line should start with a digit+dot - for line in lines: - assert not line.startswith("1.") - assert not line.startswith("2.") - - 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, 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, ollama_env): - long_pw = "A" * 129 - 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, ollama_env): - exact_pw = "B" * 128 - 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, 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, 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 ollama_globals(ollama_env.tmp_path), \ - mock.patch("hate_crack.main.urllib.request.urlopen", return_value=resp): - hc_main.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - captured = capsys.readouterr() - assert "no usable" in captured.out.lower() - - -# --------------------------------------------------------------------------- -# Step B: API error paths (non-404) -# --------------------------------------------------------------------------- - -class TestHcatOllamaApiErrors: - """Test connection errors and generic exceptions during the generate call.""" - - 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.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, ollama_env, capsys): - with ollama_globals(ollama_env.tmp_path), \ - mock.patch( - "hate_crack.main.urllib.request.urlopen", - side_effect=RuntimeError("boom"), - ): - 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, ollama_env): - """Verify the /api/generate request has correct URL, model, stream=false.""" - captured_req = {} - - def fake_urlopen(req, **kwargs): - captured_req["url"] = req.full_url - captured_req["body"] = json.loads(req.data.decode()) - return _generate_response(["Password1"]) - - 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.hcatOllama( - "0", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - assert captured_req["url"] == f"{OLLAMA_URL}/api/generate" - assert captured_req["body"]["model"] == MODEL - assert captured_req["body"]["stream"] is False - assert len(captured_req["body"]["prompt"]) > 0 - - -# --------------------------------------------------------------------------- -# Step C: Hashcat command construction -# --------------------------------------------------------------------------- - -class TestHcatOllamaHashcatCommand: - """Test hashcat command flags and process handling.""" - - 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): - popen_calls.append((list(cmd), dict(kwargs))) - return _make_proc() - - 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.hcatOllama( - "1000", ollama_env.hash_file, "wordlist", ollama_env.wordlist, - ) - - return popen_calls - - 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.pid = 99999 - proc.wait.side_effect = KeyboardInterrupt() - - with ollama_globals(ollama_env.tmp_path), \ - _urlopen_with_response(["Password1"]), \ - 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() - - -# --------------------------------------------------------------------------- -# Step F: Cleanup -# --------------------------------------------------------------------------- - -class TestHcatOllamaWordlistPersistence: - """Test that the generated wordlist file persists after the run.""" - - 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", return_value=_make_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, - ) - - candidates_path = f"{ollama_env.hash_file}.ollama_candidates" - assert os.path.isfile(candidates_path), "candidates wordlist should persist" - - -# --------------------------------------------------------------------------- -# attacks.py: ollama_attack() UI handler -# --------------------------------------------------------------------------- - -class TestOllamaAttackHandler: - """Test the ollama_attack(ctx) menu handler in attacks.py.""" - - def _make_ctx(self, **overrides): - """Build a mock ctx with the attributes ollama_attack() reads.""" - ctx = mock.MagicMock() - ctx.hcatHashType = "0" - ctx.hcatHashFile = "/tmp/hashes.txt" - ctx.hcatWordlists = "/tmp/wordlists" - for k, v in overrides.items(): - setattr(ctx, k, v) - return ctx - - def test_target_mode(self): - """ollama_attack prompts for company/industry/location and calls hcatOllama.""" - from hate_crack.attacks import ollama_attack - - ctx = self._make_ctx() - with mock.patch("builtins.input", side_effect=["AcmeCorp", "Finance", "NYC"]): - ollama_attack(ctx) - - 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" - assert target_info["industry"] == "Finance" - assert target_info["location"] == "NYC" - From 93960e2d58568108ff7a8d66a64add1444ee5394 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 12:06:12 -0400 Subject: [PATCH 07/51] test(llm): cover per-rule hashcat loop and hash:password stripping Co-Authored-By: Claude Opus 4.8 --- hate_crack/main.py | 2 ++ tests/test_hcat_ollama.py | 47 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/hate_crack/main.py b/hate_crack/main.py index 231b442..68fd1ab 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2076,6 +2076,8 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): ollamaUrl, ollamaModel, ollamaNumCtx, mode, gen_context ) except ValueError as e: + # Defensive: mode is already validated above, but keep an explicit, + # non-misleading message if generate_candidates ever rejects its input. print(f"Error: {e}") return except Exception as e: diff --git a/tests/test_hcat_ollama.py b/tests/test_hcat_ollama.py index 2da4d65..a63c4d5 100644 --- a/tests/test_hcat_ollama.py +++ b/tests/test_hcat_ollama.py @@ -77,6 +77,53 @@ def test_wordlist_mode_reads_file_and_passes_sample(ollama_env): assert "letmein" in ctx_data["sample"] +def test_wordlist_mode_strips_hash_prefix(ollama_env): + # hash:password lines should contribute only the post-colon plaintext. + dump = ollama_env.tmp_path / "dump.txt" + dump.write_text("aad3b435:Winter2024\nnocolonline\n") + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["x"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", ollama_env.hash_file, "wordlist", str(dump)) + sample = gen.call_args[0][4]["sample"] + assert "Winter2024" in sample + assert "aad3b435" not in sample + assert "nocolonline" in sample + + +def test_per_rule_runs_hashcat_with_rule_flag(ollama_env): + rules_dir = str(ollama_env.tmp_path / "rules") + os.makedirs(rules_dir, exist_ok=True) + rule_file = os.path.join(rules_dir, "test.rule") + open(rule_file, "w").close() + calls = [] + + def track_popen(cmd, **kwargs): + calls.append(list(cmd)) + return _make_proc() + + with mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), \ + mock.patch.object(hc_main, "ollamaModel", MODEL), \ + mock.patch.object(hc_main, "ollamaNumCtx", 2048), \ + mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), \ + mock.patch.object(hc_main, "hcatTuning", ""), \ + mock.patch.object(hc_main, "hcatPotfilePath", ""), \ + mock.patch.object(hc_main, "rulesDirectory", rules_dir), \ + mock.patch("hate_crack.main.generate_session_id", return_value="s"), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1"]), \ + mock.patch("subprocess.Popen", side_effect=track_popen): + hc_main.hcatOllama("1000", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + + # call 0 = plain wordlist run, call 1 = rule run + assert len(calls) == 2 + rule_call = calls[1] + assert "-r" in rule_call + assert rule_file in rule_call + + def test_missing_wordlist_prints_error(ollama_env, capsys): with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.llm.generate_candidates") as gen: From d6da721414d992769b245759f63f87df7c45d9c1 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 12:06:29 -0400 Subject: [PATCH 08/51] feat(llm): default Ollama model to qwen2.5:32b for structured output Co-Authored-By: Claude Opus 4.8 --- config.json.example | 2 +- hate_crack/main.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/config.json.example b/config.json.example index c696519..23bcb3b 100644 --- a/config.json.example +++ b/config.json.example @@ -25,7 +25,7 @@ "hashview_url": "http://localhost:8443", "hashview_api_key": "", "hashmob_api_key": "", - "ollamaModel": "mistral", + "ollamaModel": "qwen2.5:32b", "ollamaNumCtx": 2048, "omenTrainingList": "rockyou.txt", "omenMaxCandidates": 50000000, diff --git a/hate_crack/main.py b/hate_crack/main.py index 68fd1ab..31d3101 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -450,7 +450,7 @@ hcatGoodMeasureBaseList = config_parser["hcatGoodMeasureBaseList"] hcatDebugLogPath = os.path.expanduser(config_parser["hcatDebugLogPath"]) ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434") -ollamaModel = config_parser.get("ollamaModel", "mistral") +ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b") ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048)) omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt") From a4da571ab4141f1e9c2f4e250674a452d96536b3 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 12:08:20 -0400 Subject: [PATCH 09/51] feat(llm): add wordlist (denylist) mode to LLM attack menu Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/attacks.py | 40 ++++++++++++++++++++++------------ tests/test_attacks_behavior.py | 27 +++++++++++++++++++---- 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 5dee0f4..b63b17c 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -513,33 +513,45 @@ def bandrel_method(ctx: Any) -> None: def ollama_attack(ctx: Any) -> None: _notify.prompt_notify_for_attack("LLM") print("\n\tLLM Attack") - company = input("Company name: ").strip() - industry = input("Industry: ").strip() - location = input("Location: ").strip() - target_info = { - "company": company, - "industry": industry, - "location": location, - } - ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "target", target_info) + print("\t1. Target info (company / industry / location)") + print("\t2. Wordlist (generate basewords from a sample wordlist)") + choice = input("\n\tSelect generation mode: ").strip() + + if choice == "1": + company = input("Company name: ").strip() + industry = input("Industry: ").strip() + location = input("Location: ").strip() + ctx.hcatOllama( + ctx.hcatHashType, + ctx.hcatHashFile, + "target", + {"company": company, "industry": industry, "location": location}, + ) + elif choice == "2": + path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists") + if not path: + return + ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) + else: + print("\t[!] Invalid selection.") -def _omen_pick_training_wordlist(ctx: Any): - """Show wordlist picker for OMEN training. Returns path or None.""" +def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): + """Show wordlist picker. Returns path or None.""" wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) if wordlist_files: entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] max_len = max((len(e) for e in entries), default=24) print_multicolumn_list( - "Training Wordlists", + title, entries, min_col_width=max_len, max_col_width=max_len, ) print("\tp. Enter a custom path") - sel = input("\n\tSelect wordlist for training: ").strip() + sel = input("\n\tSelect wordlist: ").strip() if sel.lower() == "p": - path = input("\n\tPath to training wordlist: ").strip() + path = input("\n\tPath to wordlist: ").strip() return path if path else None try: idx = int(sel) diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index 4096819..3e7a5f2 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -329,7 +329,7 @@ class TestOllamaAttack: def test_calls_hcatOllama_with_context(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["ACME", "tech", "NYC"]): + with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]): ollama_attack(ctx) ctx.hcatOllama.assert_called_once_with( @@ -342,7 +342,7 @@ class TestOllamaAttack: def test_passes_hash_type_and_file(self) -> None: ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt") - with patch("builtins.input", side_effect=["Corp", "finance", "London"]): + with patch("builtins.input", side_effect=["1", "Corp", "finance", "London"]): ollama_attack(ctx) call_args = ctx.hcatOllama.call_args[0] @@ -352,7 +352,7 @@ class TestOllamaAttack: def test_strips_whitespace_from_inputs(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]): + with patch("builtins.input", side_effect=["1", " ACME ", " tech ", " NYC "]): ollama_attack(ctx) target_info = ctx.hcatOllama.call_args[0][3] @@ -363,7 +363,26 @@ class TestOllamaAttack: def test_target_string_is_literal_target(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["X", "Y", "Z"]): + with patch("builtins.input", side_effect=["1", "X", "Y", "Z"]): ollama_attack(ctx) assert ctx.hcatOllama.call_args[0][2] == "target" + + def test_wordlist_mode_calls_hcatOllama_with_path(self) -> None: + ctx = _make_ctx() + ctx.list_wordlist_files.return_value = ["rockyou.txt"] + ctx.hcatWordlists = "/tmp/wl" + + # mode "2", then pick wordlist "1" + with patch("builtins.input", side_effect=["2", "1"]): + ollama_attack(ctx) + + args = ctx.hcatOllama.call_args[0] + assert args[2] == "wordlist" + assert args[3].endswith("rockyou.txt") + + def test_invalid_mode_does_not_call_hcatOllama(self) -> None: + ctx = _make_ctx() + with patch("builtins.input", side_effect=["9"]): + ollama_attack(ctx) + ctx.hcatOllama.assert_not_called() From bcea02b2e7d6fff4eb94bc633a7631f40f5b35ba Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 12:10:39 -0400 Subject: [PATCH 10/51] test(llm): cover wordlist-mode abort when no wordlist picked Co-Authored-By: Claude Opus 4.8 --- tests/test_attacks_behavior.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index 3e7a5f2..2ce1bab 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -386,3 +386,11 @@ class TestOllamaAttack: with patch("builtins.input", side_effect=["9"]): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() + + def test_wordlist_mode_aborts_when_no_wordlist_picked(self) -> None: + ctx = _make_ctx() + # mode "2", then an invalid picker selection -> picker returns None + ctx.list_wordlist_files.return_value = [] + with patch("builtins.input", side_effect=["2", "nonsense"]): + ollama_attack(ctx) + ctx.hcatOllama.assert_not_called() From 519d3957082643a2fb940c16ef5e507a979d5712 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 12:11:26 -0400 Subject: [PATCH 11/51] docs(llm): document Atomic Agents refactor, new default model, wordlist mode (2.12.0) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 10 ++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfdc581..815bef3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.12.0] - 2026-07-24 + +### Changed + +- **LLM attack now uses the Atomic Agents framework** for structured (JSON) candidate + generation instead of raw HTTP + regex line-parsing. Candidate generation lives in the + new `hate_crack/llm.py` module. +- **Default Ollama model is now `qwen2.5:32b`** (was `mistral`), chosen for reliable + structured-output adherence. + +### Added + +- **Wordlist (denylist) generation mode** for the LLM attack is now reachable from the + menu: select the LLM attack (option 12), then choose "Wordlist" to derive basewords from + a sample wordlist. + +### Removed + +- **Automatic model pulling.** hate_crack no longer pulls missing Ollama models; pull them + yourself with `ollama pull `. + ## [2.11.3] - 2026-07-24 ### Fixed diff --git a/README.md b/README.md index f19da8e..720a08e 100644 --- a/README.md +++ b/README.md @@ -459,18 +459,20 @@ Set Hashview credentials in `config.json`: #### Ollama Configuration -The LLM Attack (option 15) uses Ollama to generate password candidates. Configure the model and context window in `config.json`: +The LLM Attack (option 12) uses Ollama to generate password candidates. Configure the model and context window in `config.json`: ```json { - "ollamaModel": "mistral", + "ollamaModel": "qwen2.5:32b", "ollamaNumCtx": 2048 } ``` -- **`ollamaModel`** — The Ollama model to use for candidate generation (default: `mistral`). +- **`ollamaModel`** — The Ollama model used for candidate generation (default: `qwen2.5:32b`). The LLM attack uses structured (JSON) output, so choose a model with good tool/JSON support. - **`ollamaNumCtx`** — Context window size for the model (default: `2048`). -- The Ollama URL defaults to `http://localhost:11434`. Ensure Ollama is running before using the LLM Attack. +- The Ollama URL defaults to `http://localhost:11434` (override via the `OLLAMA_HOST` env var). Ensure Ollama is running and the model is pulled (`ollama pull qwen2.5:32b`) before using the LLM Attack — hate_crack no longer auto-pulls missing models. + +The attack offers two generation modes: **Target info** (company / industry / location) and **Wordlist** (derive denylist basewords from a sample wordlist). ### Notifications (menu option 82) From f424783eb2f826f76011dd78286ca0b53c3b61bb Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 12:15:10 -0400 Subject: [PATCH 12/51] style(llm): drop vestigial global hcatProcess in hcatOllama Co-Authored-By: Claude Opus 4.8 --- hate_crack/main.py | 1 - 1 file changed, 1 deletion(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index 31d3101..e9fc3a0 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2037,7 +2037,6 @@ def hcatBandrel(hcatHashType, hcatHashFile): # LLM Ollama Attack def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): - global hcatProcess candidates_path = f"{hcatHashFile}.ollama_candidates" # Step A: normalize context into the dict generate_candidates expects. From ba6bb999bb5408869bcc7eca8d1cca7a410f3fef Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:47:32 +0000 Subject: [PATCH 13/51] chore(ci): bump softprops/action-gh-release from 2.6.2 to 3.0.2 Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2.6.2 to 3.0.2. - [Release notes](https://github.com/softprops/action-gh-release/releases) - [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md) - [Commits](https://github.com/softprops/action-gh-release/compare/3bb12739c298aeb8a4eeaf626c5b8d85266b0e65...3d0d9888cb7fd7b750713d6e236d1fcb99157228) --- updated-dependencies: - dependency-name: softprops/action-gh-release dependency-version: 3.0.2 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97347e0..0654ee6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,6 +16,6 @@ jobs: with: persist-credentials: false - - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2 + - uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2 with: generate_release_notes: true From 415255e1ec40463d18ebebacb82e20ef95588161 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:48:01 +0000 Subject: [PATCH 14/51] chore(deps-dev): bump pytest-cov from 7.0.0 to 7.1.0 Bumps [pytest-cov](https://github.com/pytest-dev/pytest-cov) from 7.0.0 to 7.1.0. - [Changelog](https://github.com/pytest-dev/pytest-cov/blob/master/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest-cov/compare/v7.0.0...v7.1.0) --- updated-dependencies: - dependency-name: pytest-cov dependency-version: 7.1.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..459a967 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,6 @@ dev = [ "ty==0.0.17", "ruff==0.15.1", "pytest==9.0.3", - "pytest-cov==7.0.0", + "pytest-cov==7.1.0", "pytest-timeout>=2.4.0", ] From 14c332d2b162885d16173a073b2c1b96766e2e14 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:48:07 +0000 Subject: [PATCH 15/51] chore(deps): update packaging requirement from >=21.0 to >=26.2 Updates the requirements on [packaging](https://github.com/pypa/packaging) to permit the latest version. - [Release notes](https://github.com/pypa/packaging/releases) - [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pypa/packaging/compare/21.0...26.2) --- updated-dependencies: - dependency-name: packaging dependency-version: '26.2' dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..c6d1227 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "requests>=2.31.0", "beautifulsoup4>=4.12.0", "openpyxl>=3.0.0", - "packaging>=21.0", + "packaging>=26.2", "simple-term-menu==1.6.6", "click>=8.0.0", ] From 9297670980b18472bf9676f50201ca9576394e8b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:48:08 +0000 Subject: [PATCH 16/51] chore(deps): update requests requirement from >=2.31.0 to >=2.34.2 Updates the requirements on [requests](https://github.com/psf/requests) to permit the latest version. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.31.0...v2.34.2) --- updated-dependencies: - dependency-name: requests dependency-version: 2.34.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..e40a19b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ description = "Menu driven Python wrapper for hashcat" readme = "README.md" requires-python = ">=3.13" dependencies = [ - "requests>=2.31.0", + "requests>=2.34.2", "beautifulsoup4>=4.12.0", "openpyxl>=3.0.0", "packaging>=21.0", From b385f391a408d535897c15c2f09f9b6e4b781d1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:48:13 +0000 Subject: [PATCH 17/51] chore(deps): update click requirement from >=8.0.0 to >=8.4.2 Updates the requirements on [click](https://github.com/pallets/click) to permit the latest version. - [Release notes](https://github.com/pallets/click/releases) - [Changelog](https://github.com/pallets/click/blob/main/CHANGES.md) - [Commits](https://github.com/pallets/click/compare/8.0.0...8.4.2) --- updated-dependencies: - dependency-name: click dependency-version: 8.4.2 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..2bb559c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ "openpyxl>=3.0.0", "packaging>=21.0", "simple-term-menu==1.6.6", - "click>=8.0.0", + "click>=8.4.2", ] [project.scripts] From a81cdf72096467706a864a80c3520465d2c8194b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 20:51:58 +0000 Subject: [PATCH 18/51] chore(deps-dev): update setuptools requirement from >=69 to >=83.0.0 Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v69.0.0...v83.0.0) --- updated-dependencies: - dependency-name: setuptools dependency-version: 83.0.0 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd7d348..e9265da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=69", "setuptools-scm>=8"] +requires = ["setuptools>=83.0.0", "setuptools-scm>=8"] build-backend = "setuptools.build_meta" [project] From e49be6122a3b3044178cba0b41d2e8b0bd2deed1 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 17:07:19 -0400 Subject: [PATCH 19/51] build(deps): declare instructor, openai, and pydantic explicitly hate_crack/llm.py imports all three directly, but only atomic-agents was declared -- they were available purely as transitive deps. A future atomic-agents release that loosened its coupling to any of them would make `uv sync` silently omit it and break `import hate_crack.llm` at runtime. Co-Authored-By: Claude --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index dbf3f95..2d0a432 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,11 @@ dependencies = [ "simple-term-menu==1.6.6", "click>=8.4.2", "atomic-agents>=2.0.0", + # Imported directly by hate_crack/llm.py; declared explicitly rather than + # relying on atomic-agents pulling them in transitively. + "instructor>=1.14.5", + "openai>=2.48.0", + "pydantic>=2.13.4", ] [project.scripts] From 549c5a0a64a68d48a706a1ccbd332c9c80caabcd Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 17:17:58 -0400 Subject: [PATCH 20/51] fix(llm): bound Ollama requests with a configurable timeout The Atomic Agents client was built with no timeout, so an Ollama server that accepted the TCP connection but never replied (most commonly a large model still loading into VRAM) left the CLI blocked in agent.run() forever. The caller's `except Exception` handler only ever fired on connection-refused. - llm.generate_candidates() takes a `timeout` parameter (default DEFAULT_TIMEOUT_SECONDS = 300.0) and forwards it to the OpenAI client. - openai.APITimeoutError is translated into a new domain-level llm.LLMTimeoutError so main.py need not import openai itself, keeping the atomic-agents/instructor dependency isolated to llm.py as documented. - hcatOllama passes the new `ollamaTimeout` config value and prints timeout-specific guidance (elapsed seconds, VRAM-loading hint, the setting to raise) instead of the misleading "ensure Ollama is running" message. - Documented `ollamaTimeout` in config.json.example and README. Also closes two test gaps: the defensive `except ValueError` handler in hcatOllama now has coverage, and the misleadingly-named `test_unknown_mode_raises` is split into explicit unit tests of _build_request's mode validation. Ticked the completed steps in the LLM Atomic Agents plan doc. Co-Authored-By: Claude --- CHANGELOG.md | 9 +++++++ README.md | 6 +++-- config.json.example | 1 + hate_crack/llm.py | 30 +++++++++++++++++++---- hate_crack/main.py | 15 +++++++++++- tests/test_hcat_ollama.py | 42 ++++++++++++++++++++++++++++++++ tests/test_llm.py | 50 +++++++++++++++++++++++++++++++++++++-- 7 files changed, 143 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cf55af..33c3e84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,15 @@ Dates are omitted for releases predating this file; see the git tags for exact t menu: select the LLM attack (option 12), then choose "Wordlist" to derive basewords from a sample wordlist. +### Fixed + +- **The LLM attack no longer hangs forever waiting on Ollama.** Generation requests are now + bounded by a configurable timeout (`ollamaTimeout` in `config.json`, default 300 seconds). + Previously, if Ollama accepted the connection but never replied — most commonly a large + model still loading into VRAM — hate_crack sat at a frozen prompt with no recourse but + Ctrl-C. When the timeout fires you now get a specific message naming the elapsed timeout + and the setting to raise, instead of a misleading "ensure Ollama is running" hint. + ### Removed - **Automatic model pulling.** hate_crack no longer pulls missing Ollama models; pull them diff --git a/README.md b/README.md index 720a08e..9c24748 100644 --- a/README.md +++ b/README.md @@ -459,17 +459,19 @@ Set Hashview credentials in `config.json`: #### Ollama Configuration -The LLM Attack (option 12) uses Ollama to generate password candidates. Configure the model and context window in `config.json`: +The LLM Attack (option 12) uses Ollama to generate password candidates. Configure the model, context window, and request timeout in `config.json`: ```json { "ollamaModel": "qwen2.5:32b", - "ollamaNumCtx": 2048 + "ollamaNumCtx": 2048, + "ollamaTimeout": 300 } ``` - **`ollamaModel`** — The Ollama model used for candidate generation (default: `qwen2.5:32b`). The LLM attack uses structured (JSON) output, so choose a model with good tool/JSON support. - **`ollamaNumCtx`** — Context window size for the model (default: `2048`). +- **`ollamaTimeout`** — Seconds to wait for a generation response before giving up (default: `300`). Raise this if a large model is still loading into VRAM on the first request, which can otherwise exceed the timeout; hate_crack prints the elapsed timeout and this setting's name when it fires. - The Ollama URL defaults to `http://localhost:11434` (override via the `OLLAMA_HOST` env var). Ensure Ollama is running and the model is pulled (`ollama pull qwen2.5:32b`) before using the LLM Attack — hate_crack no longer auto-pulls missing models. The attack offers two generation modes: **Target info** (company / industry / location) and **Wordlist** (derive denylist basewords from a sample wordlist). diff --git a/config.json.example b/config.json.example index 23bcb3b..9efabe3 100644 --- a/config.json.example +++ b/config.json.example @@ -27,6 +27,7 @@ "hashmob_api_key": "", "ollamaModel": "qwen2.5:32b", "ollamaNumCtx": 2048, + "ollamaTimeout": 300, "omenTrainingList": "rockyou.txt", "omenMaxCandidates": 50000000, "pcfgRuleset": "DEFAULT", diff --git a/hate_crack/llm.py b/hate_crack/llm.py index 89ecd88..184d4b0 100644 --- a/hate_crack/llm.py +++ b/hate_crack/llm.py @@ -5,13 +5,22 @@ to this module only through ``generate_candidates``. """ import instructor -from openai import OpenAI +from openai import APITimeoutError, OpenAI from pydantic import Field from atomic_agents import AgentConfig, AtomicAgent, BaseIOSchema from atomic_agents.context import SystemPromptGenerator MAX_CANDIDATE_LEN = 128 +DEFAULT_TIMEOUT_SECONDS = 300.0 + + +class LLMTimeoutError(Exception): + """The LLM server accepted the request but did not respond in time. + + Raised instead of ``openai.APITimeoutError`` so callers do not need to import + ``openai`` themselves. + """ class GenerationInput(BaseIOSchema): @@ -93,17 +102,23 @@ def generate_candidates( num_ctx: int, mode: str, context_data: dict, + timeout: float = DEFAULT_TIMEOUT_SECONDS, ) -> list[str]: """Generate password candidates via an Ollama-backed AtomicAgent. + ``timeout`` is the number of seconds to wait for a generation response before + giving up; it bounds the whole request so a server that accepts the + connection but never replies (e.g. a large model still loading into VRAM) + cannot hang the caller forever. + Returns a deduped, length-capped list of candidate strings (may be empty). - Raises ValueError for an unknown mode. Client/connection errors propagate to - the caller. + Raises ValueError for an unknown mode and LLMTimeoutError if the request + exceeds ``timeout``. Other client/connection errors propagate to the caller. """ request = _build_request(mode, context_data) client = instructor.from_openai( - OpenAI(base_url=f"{url}/v1", api_key="ollama"), + OpenAI(base_url=f"{url}/v1", api_key="ollama", timeout=timeout), mode=instructor.Mode.JSON, ) prompt_generator = _TARGET_PROMPT if mode == "target" else _WORDLIST_PROMPT @@ -117,7 +132,12 @@ def generate_candidates( ) ) - result = agent.run(GenerationInput(request=request)) + try: + result = agent.run(GenerationInput(request=request)) + except APITimeoutError as e: + raise LLMTimeoutError( + f"no response from {url} within {timeout:g} seconds" + ) from e seen: set[str] = set() candidates: list[str] = [] diff --git a/hate_crack/main.py b/hate_crack/main.py index e9fc3a0..b801441 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -452,6 +452,7 @@ hcatDebugLogPath = os.path.expanduser(config_parser["hcatDebugLogPath"]) ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434") ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b") ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048)) +ollamaTimeout = float(config_parser.get("ollamaTimeout", 300)) omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt") omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000)) @@ -2072,8 +2073,20 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): print(f"Generating password candidates via Ollama ({ollamaModel})...") try: candidates = llm.generate_candidates( - ollamaUrl, ollamaModel, ollamaNumCtx, mode, gen_context + ollamaUrl, + ollamaModel, + ollamaNumCtx, + mode, + gen_context, + timeout=ollamaTimeout, ) + except llm.LLMTimeoutError: + print(f"Error: the Ollama request timed out after {ollamaTimeout:g} seconds.") + print( + f"The model ({ollamaModel}) may still be loading into VRAM. Retry, or " + 'raise "ollamaTimeout" in config.json to wait longer.' + ) + return except ValueError as e: # Defensive: mode is already validated above, but keep an explicit, # non-misleading message if generate_candidates ever rejects its input. diff --git a/tests/test_hcat_ollama.py b/tests/test_hcat_ollama.py index a63c4d5..7786535 100644 --- a/tests/test_hcat_ollama.py +++ b/tests/test_hcat_ollama.py @@ -179,6 +179,48 @@ def test_generation_error_reports_and_aborts(ollama_env, capsys): popen.assert_not_called() +def test_timeout_error_reports_timeout_guidance(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch.object(hc_main, "ollamaTimeout", 300.0), \ + mock.patch("hate_crack.main.llm.generate_candidates", + side_effect=hc_main.llm.LLMTimeoutError("timed out")), \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + captured = capsys.readouterr() + assert "timed out" in captured.out.lower() + assert "300" in captured.out + assert "ollamaTimeout" in captured.out + assert "Ensure Ollama is running" not in captured.out + popen.assert_not_called() + assert not os.path.isfile(f"{ollama_env.hash_file}.ollama_candidates") + + +def test_value_error_reports_message_and_aborts(ollama_env, capsys): + """Covers the defensive `except ValueError` handler in hcatOllama.""" + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + side_effect=ValueError("boom")), \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + captured = capsys.readouterr() + assert "Error: boom" in captured.out + popen.assert_not_called() + assert not os.path.isfile(f"{ollama_env.hash_file}.ollama_candidates") + + +def test_timeout_config_forwarded_to_generate_candidates(ollama_env): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch.object(hc_main, "ollamaTimeout", 77.0), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Password1"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", ollama_env.hash_file, "target", + {"company": "X", "industry": "Y", "location": "Z"}) + assert gen.call_args.kwargs["timeout"] == 77.0 + + def test_unknown_mode_prints_error(ollama_env, capsys): with ollama_globals(ollama_env.tmp_path), \ mock.patch("hate_crack.main.llm.generate_candidates") as gen: diff --git a/tests/test_llm.py b/tests/test_llm.py index f555bfc..f34b3b8 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -3,7 +3,9 @@ import os from unittest import mock +import httpx import instructor +import openai import pytest os.environ["HATE_CRACK_SKIP_INIT"] = "1" @@ -94,8 +96,52 @@ def test_num_ctx_forwarded_via_model_api_parameters(): assert config.model_api_parameters["extra_body"]["options"]["num_ctx"] == 4096 -def test_unknown_mode_raises(): - with pytest.raises(ValueError): +def test_build_request_rejects_unknown_mode(): + """Unit test of _build_request's mode validation only — no agent involved.""" + with pytest.raises(ValueError, match="Unknown LLM generation mode: bogus"): + llm._build_request("bogus", {}) + + +def test_generate_candidates_rejects_unknown_mode_before_building_client(): + """generate_candidates surfaces _build_request's ValueError to its caller.""" + with pytest.raises(ValueError, match="Unknown LLM generation mode: bogus"): llm.generate_candidates( "http://localhost:11434", "qwen2.5:32b", 2048, "bogus", {}, ) + + +def test_timeout_forwarded_to_openai_client(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai as openai_cls, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + timeout=42.5, + ) + assert openai_cls.call_args.kwargs["timeout"] == 42.5 + + +def test_default_timeout_used_when_omitted(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai as openai_cls, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + ) + assert llm.DEFAULT_TIMEOUT_SECONDS == 300.0 + assert openai_cls.call_args.kwargs["timeout"] == llm.DEFAULT_TIMEOUT_SECONDS + + +def test_api_timeout_reraised_as_domain_error(): + """openai.APITimeoutError is translated into llm.LLMTimeoutError.""" + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + agent_instance.run.side_effect = openai.APITimeoutError( + request=httpx.Request("POST", "http://localhost:11434/v1/chat/completions") + ) + with p_instr, p_openai, p_agent: + with pytest.raises(llm.LLMTimeoutError): + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + timeout=1.0, + ) From e88315dfb9003942ae30f3cff4263223dc835e70 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 17:39:20 -0400 Subject: [PATCH 21/51] build: pin local uv Python to 3.13 requires-python is ">=3.13", so a fresh worktree's "uv sync --dev" picks the newest interpreter on the box - currently CPython 3.15.0a7 - and the build fails because pyo3 0.26 (via jiter/fastuuid/pydantic-core) does not support 3.15 yet: error: failed to run custom build command for `pyo3-ffi v0.26.0` CI already pins 3.13 via setup-uv, so this only bit local worktrees. Pinning matches CI and leaves requires-python alone. Co-Authored-By: Claude --- .python-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .python-version diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 From e5e1107359fb8916d8ba6c4c05e340ed0f6b1b83 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 17:49:24 -0400 Subject: [PATCH 22/51] fix(llm): add spinner during Ollama generation and cap wordlist sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defect 1 — hcatOllama showed a plain print then blocked silently for up to 300 s while waiting for the LLM. A new generic context manager `hate_crack/progress.py::spinner()` now runs a daemon thread that repaints a single line with a frame + elapsed-seconds counter every ~120 ms. TTY guard ensures non-TTY/test environments get a single plain print instead of ANSI control characters. The line is erased with \033[2K\r on exit (normal and exceptional). Defect 2 — the wordlist path materialised every line in memory and built a prompt that could massively overflow ollamaNumCtx on large wordlists. The path now does a two-pass evenly-spaced sample: pass 1 counts usable lines, pass 2 stride-selects up to `ollamaMaxSampleLines` (default 500, new config key) spread across the full file. When no capping occurs the message reads "Loaded N passwords"; when capping occurs it reads "Sampled N of M passwords". Co-Authored-By: Claude --- config.json.example | 1 + hate_crack/main.py | 88 +++++++--- hate_crack/progress.py | 65 +++++++ tests/test_ollama_wordlist_sampling.py | 231 +++++++++++++++++++++++++ tests/test_progress_spinner.py | 108 ++++++++++++ 5 files changed, 472 insertions(+), 21 deletions(-) create mode 100644 hate_crack/progress.py create mode 100644 tests/test_ollama_wordlist_sampling.py create mode 100644 tests/test_progress_spinner.py diff --git a/config.json.example b/config.json.example index 9efabe3..86bd196 100644 --- a/config.json.example +++ b/config.json.example @@ -28,6 +28,7 @@ "ollamaModel": "qwen2.5:32b", "ollamaNumCtx": 2048, "ollamaTimeout": 300, + "ollamaMaxSampleLines": 500, "omenTrainingList": "rockyou.txt", "omenMaxCandidates": 50000000, "pcfgRuleset": "DEFAULT", diff --git a/hate_crack/main.py b/hate_crack/main.py index b801441..32e50e1 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -74,6 +74,7 @@ from hate_crack.cli import ( # noqa: E402 ) from hate_crack import attacks as _attacks # noqa: E402 from hate_crack import llm # noqa: E402 +from hate_crack.progress import spinner # noqa: E402 from hate_crack.menu import interactive_menu # noqa: E402 from hate_crack.username_detect import detect_username_hash_format # noqa: E402 @@ -453,6 +454,7 @@ ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434") ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b") ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048)) ollamaTimeout = float(config_parser.get("ollamaTimeout", 300)) +ollamaMaxSampleLines = int(config_parser.get("ollamaMaxSampleLines", 500)) omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt") omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000)) @@ -2046,23 +2048,67 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): if not os.path.isfile(wordlist_path): print(f"Error: Wordlist not found: {wordlist_path}") return - lines = [] + + # Two-pass evenly-spaced sample: first count usable lines so we can + # stride-select across the whole file rather than taking a head slice. + # A head-only sample misses the pattern variation across large wordlists + # (e.g. rockyou.txt becomes more random further in). + def _usable(raw: str) -> str: + """Return the usable plaintext from a raw line, or empty string.""" + stripped = raw.strip() + if not stripped: + return "" + if ":" in stripped: + stripped = stripped.split(":", 1)[1] + return stripped + try: + total_usable = 0 with open(wordlist_path, "r", errors="ignore") as f: - for line in f: - stripped = line.strip() - if not stripped: - continue - # hash:password -> password - if ":" in stripped: - stripped = stripped.split(":", 1)[1] - if stripped: - lines.append(stripped) + for raw in f: + if _usable(raw): + total_usable += 1 except Exception as e: print(f"Error reading wordlist: {e}") return - print(f"Loaded {len(lines)} passwords from wordlist.") - gen_context = {"sample": "\n".join(lines)} + + cap = ollamaMaxSampleLines + if total_usable <= cap: + # No capping needed — collect all usable lines. + try: + sampled: list[str] = [] + with open(wordlist_path, "r", errors="ignore") as f: + for raw in f: + w = _usable(raw) + if w: + sampled.append(w) + except Exception as e: + print(f"Error reading wordlist: {e}") + return + print(f"Loaded {len(sampled):,} passwords from wordlist.") + else: + # Evenly-spaced stride across the file to cover its full pattern + # range without materialising all lines in memory. + stride = total_usable / cap + try: + sampled = [] + usable_idx = 0.0 + next_pick = stride / 2 # start near centre of first bucket + with open(wordlist_path, "r", errors="ignore") as f: + for raw in f: + w = _usable(raw) + if not w: + continue + if usable_idx >= next_pick and len(sampled) < cap: + sampled.append(w) + next_pick += stride + usable_idx += 1 + except Exception as e: + print(f"Error reading wordlist: {e}") + return + print(f"Sampled {len(sampled):,} of {total_usable:,} passwords from wordlist.") + + gen_context = {"sample": "\n".join(sampled)} elif mode == "target": gen_context = context_data else: @@ -2070,16 +2116,16 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): return # Step B: generate candidates via the Atomic Agents module. - print(f"Generating password candidates via Ollama ({ollamaModel})...") try: - candidates = llm.generate_candidates( - ollamaUrl, - ollamaModel, - ollamaNumCtx, - mode, - gen_context, - timeout=ollamaTimeout, - ) + with spinner(f"Generating password candidates via Ollama ({ollamaModel})..."): + candidates = llm.generate_candidates( + ollamaUrl, + ollamaModel, + ollamaNumCtx, + mode, + gen_context, + timeout=ollamaTimeout, + ) except llm.LLMTimeoutError: print(f"Error: the Ollama request timed out after {ollamaTimeout:g} seconds.") print( diff --git a/hate_crack/progress.py b/hate_crack/progress.py new file mode 100644 index 0000000..3827533 --- /dev/null +++ b/hate_crack/progress.py @@ -0,0 +1,65 @@ +"""Generic terminal progress utilities for hate_crack. + +Provides a context manager that shows a live spinner with elapsed-seconds +counter while a blocking operation runs. Safe to use in non-TTY environments: +if stdout is not a TTY the message is printed once and no background thread is +started. +""" + +from __future__ import annotations + +import sys +import threading +import time +from collections.abc import Generator +from contextlib import contextmanager + +_SPINNER_FRAMES = ["|", "/", "-", "\\"] +_TICK_INTERVAL = 0.12 # seconds between repaints (~120 ms) + + +@contextmanager +def spinner(message: str) -> "Generator[None, None, None]": + """Context manager that shows *message* plus a live elapsed-seconds counter. + + While the body executes a daemon thread repaints a single terminal line + roughly every 120 ms showing: + + | Generating password candidates via Ollama (model)... 3s + + On exit (normal or exceptional) the spinner line is erased so subsequent + output starts on a clean line. + + TTY guard: if ``sys.stdout.isatty()`` is False the message is printed once + via ``print()`` and no thread is started, keeping piped output and the test + suite clean. + """ + if not sys.stdout.isatty(): + print(message) + yield + return + + stop_event = threading.Event() + start_time = time.monotonic() + + def _run() -> None: + frame_idx = 0 + while not stop_event.is_set(): + elapsed = int(time.monotonic() - start_time) + frame = _SPINNER_FRAMES[frame_idx % len(_SPINNER_FRAMES)] + line = f"\r{frame} {message} {elapsed}s" + sys.stdout.write(line) + sys.stdout.flush() + frame_idx += 1 + stop_event.wait(_TICK_INTERVAL) + + thread = threading.Thread(target=_run, daemon=True) + thread.start() + try: + yield + finally: + stop_event.set() + thread.join() + # Erase the spinner line so the next print starts clean. + sys.stdout.write("\033[2K\r") + sys.stdout.flush() diff --git a/tests/test_ollama_wordlist_sampling.py b/tests/test_ollama_wordlist_sampling.py new file mode 100644 index 0000000..305ab4c --- /dev/null +++ b/tests/test_ollama_wordlist_sampling.py @@ -0,0 +1,231 @@ +"""Tests for the capped / evenly-sampled wordlist path in hcatOllama. + +Covers: +- ollamaMaxSampleLines config default (500) +- Small wordlist (< cap): all lines included, "Loaded N" message +- Large wordlist (> cap): exactly `cap` lines sampled, "Sampled N of M" message +- Evenly-spaced sample covers the whole file (first and last entries present) +- hash:password splitting and blank-line skipping still work +- spinner is called with the right message regardless of TTY +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from types import SimpleNamespace +from unittest import mock + +import pytest + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import main as hc_main # noqa: E402 + +OLLAMA_URL = "http://localhost:11434" +MODEL = "test-model" + + +@contextmanager +def _ollama_globals(tmp_path, *, max_sample: int = 500): + rules_dir = str(tmp_path / "rules") + os.makedirs(rules_dir, exist_ok=True) + with ( + mock.patch.object(hc_main, "ollamaUrl", OLLAMA_URL), + mock.patch.object(hc_main, "ollamaModel", MODEL), + mock.patch.object(hc_main, "ollamaNumCtx", 2048), + mock.patch.object(hc_main, "ollamaMaxSampleLines", max_sample), + mock.patch.object(hc_main, "hcatBin", "/usr/bin/hashcat"), + mock.patch.object(hc_main, "hcatTuning", ""), + mock.patch.object(hc_main, "hcatPotfilePath", ""), + mock.patch.object(hc_main, "rulesDirectory", rules_dir), + mock.patch("hate_crack.main.generate_session_id", return_value="s"), + ): + yield + + +def _make_proc(rc: int = 0): + proc = mock.MagicMock() + proc.wait.return_value = rc + proc.communicate.return_value = (b"", b"") + proc.returncode = rc + return proc + + +@pytest.fixture +def env(tmp_path): + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + return SimpleNamespace(tmp_path=tmp_path, hash_file=str(hash_file)) + + +# --------------------------------------------------------------------------- +# Config default +# --------------------------------------------------------------------------- + + +def test_ollama_max_sample_lines_default(): + """ollamaMaxSampleLines must default to 500.""" + assert hc_main.ollamaMaxSampleLines == 500 + + +# --------------------------------------------------------------------------- +# Small wordlist — no capping +# --------------------------------------------------------------------------- + + +def test_small_wordlist_loads_all(env, capsys): + """When total lines < cap, all are included and message says 'Loaded'.""" + wl = env.tmp_path / "small.txt" + wl.write_text("alpha\nbeta\ngamma\n") + + with _ollama_globals(env.tmp_path), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + assert "Loaded 3" in captured.out + # All three words must appear in the sample passed to the LLM + sample = gen.call_args[0][4]["sample"] + assert "alpha" in sample + assert "beta" in sample + assert "gamma" in sample + + +def test_small_wordlist_no_sampled_message(env, capsys): + """'Sampled N of M' must NOT appear when no capping occurred.""" + wl = env.tmp_path / "small.txt" + wl.write_text("a\nb\nc\n") + + with _ollama_globals(env.tmp_path, max_sample=500), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + assert "Sampled" not in captured.out + + +# --------------------------------------------------------------------------- +# Large wordlist — capping +# --------------------------------------------------------------------------- + + +def _make_large_wordlist(path, n: int) -> None: + """Write n unique numbered lines to path.""" + lines = "\n".join(f"word{i:06d}" for i in range(n)) + path.write_text(lines + "\n") + + +def test_large_wordlist_caps_to_max(env, capsys): + """When total > cap, exactly cap lines are sampled.""" + wl = env.tmp_path / "big.txt" + _make_large_wordlist(wl, 1000) + + captured_ctx: list[dict] = [] + + def _capture_gen(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + with _ollama_globals(env.tmp_path, max_sample=50), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture_gen + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + sample_lines = captured_ctx[0]["sample"].splitlines() + assert len(sample_lines) == 50 + + captured = capsys.readouterr() + assert "Sampled 50 of 1,000" in captured.out + + +def test_large_wordlist_covers_full_range(env): + """Evenly-spaced sample must contain entries from both the start and end of the file.""" + wl = env.tmp_path / "range.txt" + _make_large_wordlist(wl, 1000) + + captured_ctx: list[dict] = [] + + def _capture_gen(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + # Use a small cap (10) over 1000 lines so the stride is clearly 100. + with _ollama_globals(env.tmp_path, max_sample=10), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture_gen + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + sample_lines = captured_ctx[0]["sample"].splitlines() + # The sampled words should come from across the file, not just the head. + # word000000..word000099 are the first 100; word000900..word000999 are the last 100. + indices = [int(w.replace("word", "")) for w in sample_lines] + assert min(indices) < 100, "sample should include entries from the start of the file" + assert max(indices) >= 900, "sample should include entries from the end of the file" + + +# --------------------------------------------------------------------------- +# hash:password splitting and blank-line skipping +# --------------------------------------------------------------------------- + + +def test_wordlist_sampling_strips_hash_prefix(env): + """hash:password lines must contribute only the plaintext in capped mode.""" + wl = env.tmp_path / "dump.txt" + # 20 lines: half with colon prefix, half plain; more than max_sample=5 + lines = [f"hash{i}:plain{i:02d}" if i % 2 == 0 else f"plain{i:02d}" for i in range(20)] + wl.write_text("\n".join(lines) + "\n") + + captured_ctx: list[dict] = [] + + def _capture_gen(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + with _ollama_globals(env.tmp_path, max_sample=5), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture_gen + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + sample = captured_ctx[0]["sample"] + # No hash prefix should appear in the sample + assert "hash" not in sample + + +def test_wordlist_sampling_skips_blank_lines(env, capsys): + """Blank lines must not count toward total or be included in the sample.""" + wl = env.tmp_path / "blanks.txt" + # 3 real words surrounded by blank lines + wl.write_text("\nalpha\n\nbeta\n\ngamma\n\n") + + with _ollama_globals(env.tmp_path, max_sample=500), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + assert "Loaded 3" in captured.out + sample = gen.call_args[0][4]["sample"] + for line in sample.splitlines(): + assert line.strip() != "", "blank lines leaked into sample" + + +# --------------------------------------------------------------------------- +# Spinner is invoked (via the TTY guard — non-TTY in test suite) +# --------------------------------------------------------------------------- + + +def test_spinner_called_with_model_message(env, capsys): + """The spinner message must include the model name (non-TTY: just print).""" + wl = env.tmp_path / "s.txt" + wl.write_text("pw\n") + + with _ollama_globals(env.tmp_path), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + assert MODEL in captured.out + assert "Generating password candidates via Ollama" in captured.out diff --git a/tests/test_progress_spinner.py b/tests/test_progress_spinner.py new file mode 100644 index 0000000..c59963c --- /dev/null +++ b/tests/test_progress_spinner.py @@ -0,0 +1,108 @@ +"""Tests for hate_crack/progress.py — the generic spinner context manager.""" + +from __future__ import annotations + +import io +import sys +import threading +import time +from unittest import mock + +import pytest + +from hate_crack.progress import spinner + + +# --------------------------------------------------------------------------- +# TTY guard: non-TTY path must print message once and not start a thread +# --------------------------------------------------------------------------- + + +def test_non_tty_prints_message(capsys: pytest.CaptureFixture[str]) -> None: + """In a non-TTY environment the message is printed once and no thread runs.""" + with mock.patch.object(sys, "stdout", wraps=sys.stdout) as m_stdout: + m_stdout.isatty.return_value = False # type: ignore[attr-defined] + # Use actual capsys for the print capture but override isatty + original_isatty = sys.stdout.isatty + sys.stdout.isatty = lambda: False # type: ignore[method-assign] + try: + with spinner("Working..."): + pass + finally: + sys.stdout.isatty = original_isatty # type: ignore[method-assign] + + captured = capsys.readouterr() + assert "Working..." in captured.out + + +def test_non_tty_no_extra_thread() -> None: + """Spinner in non-TTY context should not start a background thread.""" + buf = io.StringIO() + buf.isatty = lambda: False # type: ignore[attr-defined] + + threads_before = threading.active_count() + with mock.patch("sys.stdout", buf): + with spinner("No threads please"): + peak = threading.active_count() + # The thread count during the body should not exceed baseline + 1 + # (pytest itself may add threads; we just want no extra daemon spinner thread). + assert peak <= threads_before + 1 + + +# --------------------------------------------------------------------------- +# TTY path: spinner starts a thread, clears the line on exit +# --------------------------------------------------------------------------- + + +def test_tty_clears_line_on_exit() -> None: + """After the context exits the spinner line must be erased.""" + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + with mock.patch("sys.stdout", buf): + with spinner("Testing..."): + time.sleep(0.05) # let spinner tick at least once + + output = buf.getvalue() + # Line clear sequence must be present somewhere after the spinner started. + assert "\033[2K\r" in output or ("\033[2K" in output) + + +def test_tty_shows_elapsed_seconds() -> None: + """The spinner should show at least a 0s counter in its output.""" + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + with mock.patch("sys.stdout", buf): + with spinner("Counting..."): + time.sleep(0.25) # enough time for several ticks + + output = buf.getvalue() + # Should have written at least one elapsed counter like "0s" or "1s" + assert "s" in output + assert "Counting..." in output + + +def test_tty_exception_still_clears_line() -> None: + """Spinner must clear the line even when the body raises.""" + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + with mock.patch("sys.stdout", buf): + with pytest.raises(ValueError): + with spinner("Will raise"): + raise ValueError("boom") + + output = buf.getvalue() + assert "\033[2K\r" in output or ("\033[2K" in output) + + +def test_spinner_does_not_swallow_exception() -> None: + """The spinner context manager must re-raise exceptions from the body.""" + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + with mock.patch("sys.stdout", buf): + with pytest.raises(RuntimeError, match="test error"): + with spinner("Should propagate"): + raise RuntimeError("test error") From fef606dbf493755d581ee98cd5f73662c6725c61 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 17:57:09 -0400 Subject: [PATCH 23/51] fix(llm): fix stride sampling exactness, zero-cap crash, and test quality - Defect 1 (main.py): Replace floating-point stride loop with exact floor(k * total / cap) index formula so sampling always returns EXACTLY cap items for any 1 <= cap <= total_usable, including the stride < 2 regime where the old approach returned cap-1 items. - Defect 2 (main.py): Validate ollamaMaxSampleLines before computing the stride; values <= 0 are nonsensical and fall back to 500 (same as the default) rather than raising ZeroDivisionError. - Defect 3 (test_progress_spinner.py): Rewrite test_non_tty_no_extra_thread to patch threading.Thread and assert it was never called, so the test actually fails when the spinner starts a thread in the non-TTY path. - Defect 4 (test_progress_spinner.py): Simplify test_non_tty_prints_message to use monkeypatch.setattr instead of the dead mock.patch.object + manual assign/restore layering; removes two # type: ignore comments. - docs(README.md): Document ollamaMaxSampleLines alongside ollamaNumCtx / ollamaTimeout in the Ollama Configuration section. - tests: Add boundary-case tests for stride < 2 (total==cap, total==cap+1, total=3/cap=2, cap=1) and zero-cap fallback; make test_ollama_max_sample_lines_default exercise behaviour rather than the ambient config value. Co-Authored-By: Claude --- README.md | 1 + hate_crack/main.py | 18 ++-- tests/test_ollama_wordlist_sampling.py | 115 ++++++++++++++++++++++++- tests/test_progress_spinner.py | 32 +++---- 4 files changed, 137 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 9c24748..5749108 100644 --- a/README.md +++ b/README.md @@ -472,6 +472,7 @@ The LLM Attack (option 12) uses Ollama to generate password candidates. Configur - **`ollamaModel`** — The Ollama model used for candidate generation (default: `qwen2.5:32b`). The LLM attack uses structured (JSON) output, so choose a model with good tool/JSON support. - **`ollamaNumCtx`** — Context window size for the model (default: `2048`). - **`ollamaTimeout`** — Seconds to wait for a generation response before giving up (default: `300`). Raise this if a large model is still loading into VRAM on the first request, which can otherwise exceed the timeout; hate_crack prints the elapsed timeout and this setting's name when it fires. +- **`ollamaMaxSampleLines`** — Maximum number of lines drawn from the source wordlist and included in the LLM prompt when using **Wordlist** mode (default: `500`). Lines are sampled evenly across the whole file so the prompt reflects the wordlist's full character range rather than just the head. Set to a larger value if the model has a big context window (`ollamaNumCtx`) and you want richer coverage; set it lower to reduce prompt size and generation latency. Values ≤ 0 are treated as 500. - The Ollama URL defaults to `http://localhost:11434` (override via the `OLLAMA_HOST` env var). Ensure Ollama is running and the model is pulled (`ollama pull qwen2.5:32b`) before using the LLM Attack — hate_crack no longer auto-pulls missing models. The attack offers two generation modes: **Target info** (company / industry / location) and **Wordlist** (derive denylist basewords from a sample wordlist). diff --git a/hate_crack/main.py b/hate_crack/main.py index 32e50e1..9e839f4 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2073,6 +2073,9 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): return cap = ollamaMaxSampleLines + # Defect 2: cap <= 0 is nonsense; treat it as "no cap" (default 500). + if cap <= 0: + cap = 500 if total_usable <= cap: # No capping needed — collect all usable lines. try: @@ -2087,21 +2090,22 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): return print(f"Loaded {len(sampled):,} passwords from wordlist.") else: - # Evenly-spaced stride across the file to cover its full pattern - # range without materialising all lines in memory. - stride = total_usable / cap + # Evenly-spaced sample: the k-th pick targets index floor(k * total / cap), + # which yields EXACTLY cap distinct indices spanning the full range for any + # 1 <= cap <= total_usable. try: + pick_set = { + (k * total_usable) // cap for k in range(cap) + } sampled = [] - usable_idx = 0.0 - next_pick = stride / 2 # start near centre of first bucket + usable_idx = 0 with open(wordlist_path, "r", errors="ignore") as f: for raw in f: w = _usable(raw) if not w: continue - if usable_idx >= next_pick and len(sampled) < cap: + if usable_idx in pick_set: sampled.append(w) - next_pick += stride usable_idx += 1 except Exception as e: print(f"Error reading wordlist: {e}") diff --git a/tests/test_ollama_wordlist_sampling.py b/tests/test_ollama_wordlist_sampling.py index 305ab4c..2d232b7 100644 --- a/tests/test_ollama_wordlist_sampling.py +++ b/tests/test_ollama_wordlist_sampling.py @@ -63,9 +63,47 @@ def env(tmp_path): # --------------------------------------------------------------------------- -def test_ollama_max_sample_lines_default(): - """ollamaMaxSampleLines must default to 500.""" - assert hc_main.ollamaMaxSampleLines == 500 +def test_ollama_max_sample_lines_default(env, capsys): + """ollamaMaxSampleLines fallback behaviour is 500. + + Rather than asserting on the ambient live value (which a developer can + override in their local config.json), we verify the *behaviour*: a + wordlist with exactly 500 usable lines is loaded in full (no capping), + while one with 501 lines is capped to 500. + """ + # 500 lines → no capping, uses the "Loaded N" path + wl500 = env.tmp_path / "w500.txt" + wl500.write_text("\n".join(f"w{i:04d}" for i in range(500)) + "\n") + + with mock.patch.object(hc_main, "ollamaMaxSampleLines", 500), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen500, mock.patch.object( + hc_main, "ollamaUrl", OLLAMA_URL + ), mock.patch.object( + hc_main, "ollamaModel", MODEL + ), mock.patch.object( + hc_main, "ollamaNumCtx", 2048 + ), mock.patch.object( + hc_main, "hcatBin", "/usr/bin/hashcat" + ), mock.patch.object( + hc_main, "hcatTuning", "" + ), mock.patch.object( + hc_main, "hcatPotfilePath", "" + ), mock.patch.object( + hc_main, "rulesDirectory", str(env.tmp_path / "rules") + ), mock.patch( + "hate_crack.main.generate_session_id", return_value="s" + ), mock.patch( + "subprocess.Popen", return_value=_make_proc() + ): + import os as _os + _os.makedirs(str(env.tmp_path / "rules"), exist_ok=True) + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl500)) + + captured = capsys.readouterr() + assert "Loaded 500" in captured.out + sample_lines = gen500.call_args[0][4]["sample"].splitlines() + assert len(sample_lines) == 500 # --------------------------------------------------------------------------- @@ -165,6 +203,77 @@ def test_large_wordlist_covers_full_range(env): assert max(indices) >= 900, "sample should include entries from the end of the file" +# --------------------------------------------------------------------------- +# Stride < 2 regime: boundary cases for small total_usable values +# --------------------------------------------------------------------------- + + +def _sample_count(env, total: int, cap: int) -> int: + """Helper: write a wordlist with *total* lines, run hcatOllama capped to *cap*, + return the number of lines that reached the LLM.""" + wl = env.tmp_path / f"wl_{total}_{cap}.txt" + wl.write_text("\n".join(f"p{i:04d}" for i in range(total)) + "\n") + captured_ctx: list[dict] = [] + + def _capture(*args, **kwargs): + captured_ctx.append(args[4]) + return ["x"] + + with _ollama_globals(env.tmp_path, max_sample=cap), mock.patch.object( + hc_main.llm, "generate_candidates", side_effect=_capture + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + if not captured_ctx: + return 0 + return len(captured_ctx[0]["sample"].splitlines()) + + +def test_stride_exact_total_equals_cap(env) -> None: + """total == cap: all lines must be returned (no capping branch taken).""" + assert _sample_count(env, 5, 5) == 5 + + +def test_stride_total_one_above_cap(env) -> None: + """total == cap + 1: exactly cap items must be sampled (stride < 2).""" + assert _sample_count(env, 4, 3) == 3 + + +def test_stride_tiny_three_cap_two(env) -> None: + """total=3, cap=2: exactly 2 items must be sampled (stride = 1.5 < 2).""" + assert _sample_count(env, 3, 2) == 2 + + +def test_stride_cap_one(env) -> None: + """cap=1 over a larger file must return exactly 1 item.""" + assert _sample_count(env, 10, 1) == 1 + + +# --------------------------------------------------------------------------- +# Defect 2: ZeroDivisionError guard — cap=0 falls back to 500 +# --------------------------------------------------------------------------- + + +def test_zero_cap_falls_back_to_default(env, capsys) -> None: + """ollamaMaxSampleLines=0 must not crash; it falls back to cap=500. + + A 10-line wordlist with cap=0 (invalid) should load all 10 lines using + the "no capping needed" path since 10 <= 500 (the fallback). + """ + wl = env.tmp_path / "zero_cap.txt" + wl.write_text("\n".join(f"pw{i}" for i in range(10)) + "\n") + + with _ollama_globals(env.tmp_path, max_sample=0), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + captured = capsys.readouterr() + # Must not raise; must load all 10 words (10 <= 500 fallback) + assert "Loaded 10" in captured.out + assert len(gen.call_args[0][4]["sample"].splitlines()) == 10 + + # --------------------------------------------------------------------------- # hash:password splitting and blank-line skipping # --------------------------------------------------------------------------- diff --git a/tests/test_progress_spinner.py b/tests/test_progress_spinner.py index c59963c..64a0eed 100644 --- a/tests/test_progress_spinner.py +++ b/tests/test_progress_spinner.py @@ -4,7 +4,6 @@ from __future__ import annotations import io import sys -import threading import time from unittest import mock @@ -18,35 +17,30 @@ from hate_crack.progress import spinner # --------------------------------------------------------------------------- -def test_non_tty_prints_message(capsys: pytest.CaptureFixture[str]) -> None: +def test_non_tty_prints_message( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: """In a non-TTY environment the message is printed once and no thread runs.""" - with mock.patch.object(sys, "stdout", wraps=sys.stdout) as m_stdout: - m_stdout.isatty.return_value = False # type: ignore[attr-defined] - # Use actual capsys for the print capture but override isatty - original_isatty = sys.stdout.isatty - sys.stdout.isatty = lambda: False # type: ignore[method-assign] - try: - with spinner("Working..."): - pass - finally: - sys.stdout.isatty = original_isatty # type: ignore[method-assign] + monkeypatch.setattr(sys.stdout, "isatty", lambda: False) + + with spinner("Working..."): + pass captured = capsys.readouterr() assert "Working..." in captured.out def test_non_tty_no_extra_thread() -> None: - """Spinner in non-TTY context should not start a background thread.""" + """Spinner in non-TTY context must never construct a Thread.""" buf = io.StringIO() buf.isatty = lambda: False # type: ignore[attr-defined] - threads_before = threading.active_count() - with mock.patch("sys.stdout", buf): + with mock.patch("sys.stdout", buf), mock.patch( + "hate_crack.progress.threading.Thread" + ) as mock_thread: with spinner("No threads please"): - peak = threading.active_count() - # The thread count during the body should not exceed baseline + 1 - # (pytest itself may add threads; we just want no extra daemon spinner thread). - assert peak <= threads_before + 1 + pass + mock_thread.assert_not_called() # --------------------------------------------------------------------------- From 142c1fc99fda4e85d7444e5b87ebf9c719a2bb05 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:08:11 -0400 Subject: [PATCH 24/51] refactor: address code-quality review findings on ui-polish branch - Remove review-artifact "Defect 2" comment in main.py:2076; replace with accurate description of the invalid-cap fallback. Same label removed from the test section header in test_ollama_wordlist_sampling.py. - Lift nested _usable helper to module-level _usable_plaintext in main.py, placed alongside the other private wordlist helpers after _wordlist_path. Add six direct unit tests covering blank, whitespace, plain, hash:pw, multi-colon, and empty-plaintext cases. - Move spinner line-clear before thread.join() in progress.py so the terminal is cleaned up even if join() is interrupted by a DoubleInterrupt/second Ctrl-C. Add test_tty_clears_line_even_if_join_raises to cover this path. - Replace time.sleep-based elapsed-counter test with a deterministic version that patches time.monotonic; assert on actual rendered format with r"\d+s". Drop the misleading 0.05s sleep in test_tty_clears_line_on_exit (shorter than one tick, so no frame was ever guaranteed). - Add missing ollama* keys to hate_crack/config.json.example (package-data copy); root config.json.example already had them. Co-Authored-By: Claude --- hate_crack/config.json.example | 4 ++ hate_crack/main.py | 34 ++++++++------ hate_crack/progress.py | 9 +++- tests/test_ollama_wordlist_sampling.py | 37 +++++++++++++++- tests/test_progress_spinner.py | 61 +++++++++++++++++++++++--- 5 files changed, 123 insertions(+), 22 deletions(-) diff --git a/hate_crack/config.json.example b/hate_crack/config.json.example index c20f6f8..0bd02d7 100644 --- a/hate_crack/config.json.example +++ b/hate_crack/config.json.example @@ -22,6 +22,10 @@ "hashview_url": "http://localhost:8443", "hashview_api_key": "", "hashmob_api_key": "", + "ollamaModel": "qwen2.5:32b", + "ollamaNumCtx": 2048, + "ollamaTimeout": 300, + "ollamaMaxSampleLines": 500, "passgptModel": "javirandor/passgpt-10characters", "passgptMaxCandidates": 1000000, "passgptBatchSize": 1024, diff --git a/hate_crack/main.py b/hate_crack/main.py index 9e839f4..2cce2d2 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -897,6 +897,23 @@ def _wordlist_path(path: str): yield path +def _usable_plaintext(raw: str) -> str: + """Return the usable plaintext from a raw wordlist line, or empty string. + + Blank/whitespace-only lines are discarded. Lines in ``hash:password`` + format (as produced by hashcat ``--show``) are split on the first colon + so only the plaintext portion is returned; lines with no colon are + returned as-is. A ``hash:`` line whose plaintext is empty after + stripping returns an empty string and is therefore also discarded. + """ + stripped = raw.strip() + if not stripped: + return "" + if ":" in stripped: + stripped = stripped.split(":", 1)[1] + return stripped + + def _add_debug_mode_for_rules(cmd): """Add debug mode arguments to hashcat command if rules are being used. @@ -2053,27 +2070,18 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): # stride-select across the whole file rather than taking a head slice. # A head-only sample misses the pattern variation across large wordlists # (e.g. rockyou.txt becomes more random further in). - def _usable(raw: str) -> str: - """Return the usable plaintext from a raw line, or empty string.""" - stripped = raw.strip() - if not stripped: - return "" - if ":" in stripped: - stripped = stripped.split(":", 1)[1] - return stripped - try: total_usable = 0 with open(wordlist_path, "r", errors="ignore") as f: for raw in f: - if _usable(raw): + if _usable_plaintext(raw): total_usable += 1 except Exception as e: print(f"Error reading wordlist: {e}") return cap = ollamaMaxSampleLines - # Defect 2: cap <= 0 is nonsense; treat it as "no cap" (default 500). + # Invalid cap (zero or negative): fall back to the built-in default of 500. if cap <= 0: cap = 500 if total_usable <= cap: @@ -2082,7 +2090,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): sampled: list[str] = [] with open(wordlist_path, "r", errors="ignore") as f: for raw in f: - w = _usable(raw) + w = _usable_plaintext(raw) if w: sampled.append(w) except Exception as e: @@ -2101,7 +2109,7 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): usable_idx = 0 with open(wordlist_path, "r", errors="ignore") as f: for raw in f: - w = _usable(raw) + w = _usable_plaintext(raw) if not w: continue if usable_idx in pick_set: diff --git a/hate_crack/progress.py b/hate_crack/progress.py index 3827533..0bb33a6 100644 --- a/hate_crack/progress.py +++ b/hate_crack/progress.py @@ -59,7 +59,12 @@ def spinner(message: str) -> "Generator[None, None, None]": yield finally: stop_event.set() - thread.join() - # Erase the spinner line so the next print starts clean. + # Clear the spinner line *before* joining so the terminal is left clean + # even if join() is interrupted by a second Ctrl-C (DoubleInterrupt). + # hate_crack's _sigint_handler raises DoubleInterrupt on a second SIGINT + # within 2 s, and join() can block up to _TICK_INTERVAL (0.12 s) — a + # narrow but real window. Writing the escape sequence first ensures the + # line is always erased regardless of what happens to the join. sys.stdout.write("\033[2K\r") sys.stdout.flush() + thread.join() diff --git a/tests/test_ollama_wordlist_sampling.py b/tests/test_ollama_wordlist_sampling.py index 2d232b7..b123247 100644 --- a/tests/test_ollama_wordlist_sampling.py +++ b/tests/test_ollama_wordlist_sampling.py @@ -250,7 +250,7 @@ def test_stride_cap_one(env) -> None: # --------------------------------------------------------------------------- -# Defect 2: ZeroDivisionError guard — cap=0 falls back to 500 +# Invalid-cap guard — zero/negative ollamaMaxSampleLines falls back to 500 # --------------------------------------------------------------------------- @@ -338,3 +338,38 @@ def test_spinner_called_with_model_message(env, capsys): captured = capsys.readouterr() assert MODEL in captured.out assert "Generating password candidates via Ollama" in captured.out + + +# --------------------------------------------------------------------------- +# _usable_plaintext unit tests +# --------------------------------------------------------------------------- + + +def test_usable_plaintext_blank_line(): + """A blank line must return an empty string (discarded).""" + assert hc_main._usable_plaintext("") == "" + + +def test_usable_plaintext_whitespace_only(): + """A whitespace-only line must return an empty string (discarded).""" + assert hc_main._usable_plaintext(" \t ") == "" + + +def test_usable_plaintext_plain_password(): + """A plain password line (no colon) is returned stripped.""" + assert hc_main._usable_plaintext("hunter2\n") == "hunter2" + + +def test_usable_plaintext_hash_colon_password(): + """A hash:password line returns only the password portion.""" + assert hc_main._usable_plaintext("aabbcc:hunter2") == "hunter2" + + +def test_usable_plaintext_multiple_colons(): + """A line with multiple colons splits only on the first colon.""" + assert hc_main._usable_plaintext("aabbcc:p@ss:word") == "p@ss:word" + + +def test_usable_plaintext_hash_colon_empty(): + """A hash: line with no plaintext after the colon returns empty string.""" + assert hc_main._usable_plaintext("aabbcc:") == "" diff --git a/tests/test_progress_spinner.py b/tests/test_progress_spinner.py index 64a0eed..c54c68b 100644 --- a/tests/test_progress_spinner.py +++ b/tests/test_progress_spinner.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import re import sys import time from unittest import mock @@ -55,7 +56,7 @@ def test_tty_clears_line_on_exit() -> None: with mock.patch("sys.stdout", buf): with spinner("Testing..."): - time.sleep(0.05) # let spinner tick at least once + pass # no sleep needed — the finally block writes the escape regardless output = buf.getvalue() # Line clear sequence must be present somewhere after the spinner started. @@ -63,18 +64,29 @@ def test_tty_clears_line_on_exit() -> None: def test_tty_shows_elapsed_seconds() -> None: - """The spinner should show at least a 0s counter in its output.""" + """The spinner must render the elapsed-seconds counter in the expected format. + + time.monotonic is patched so the test is deterministic regardless of + scheduling: the first call returns 0.0 (start), the second returns 3.0 + (first tick), giving a stable "3s" label without sleeping. + """ buf = io.StringIO() buf.isatty = lambda: True # type: ignore[attr-defined] - with mock.patch("sys.stdout", buf): + # Patch monotonic: start=0.0, then 3.0 at the first tick, then keep + # returning 3.0 so stop_event.wait() ends quickly. + mono_values = iter([0.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0, 3.0]) + + with mock.patch("sys.stdout", buf), mock.patch( + "hate_crack.progress.time.monotonic", side_effect=mono_values + ): with spinner("Counting..."): - time.sleep(0.25) # enough time for several ticks + pass # body exits immediately; the thread gets one tick then stops output = buf.getvalue() - # Should have written at least one elapsed counter like "0s" or "1s" - assert "s" in output assert "Counting..." in output + # At least one frame must match the "Ns" elapsed format (e.g. "3s"). + assert re.search(r"\d+s", output), f"Expected elapsed counter in output: {output!r}" def test_tty_exception_still_clears_line() -> None: @@ -100,3 +112,40 @@ def test_spinner_does_not_swallow_exception() -> None: with pytest.raises(RuntimeError, match="test error"): with spinner("Should propagate"): raise RuntimeError("test error") + + +# --------------------------------------------------------------------------- +# Teardown robustness: line must be cleared even if join() is interrupted +# --------------------------------------------------------------------------- + + +def test_tty_clears_line_even_if_join_raises() -> None: + """Line-clear must happen even when thread.join() raises (e.g. DoubleInterrupt). + + The fix moves the write("\033[2K\r") *before* thread.join() so an + exception during join cannot prevent the terminal from being cleaned up. + """ + buf = io.StringIO() + buf.isatty = lambda: True # type: ignore[attr-defined] + + class FakeThread: + def __init__(self, *args, **kwargs) -> None: + pass + + def start(self) -> None: + pass + + def join(self, *args, **kwargs) -> None: + raise KeyboardInterrupt("simulated double Ctrl-C during join") + + with mock.patch("hate_crack.progress.threading.Thread", FakeThread), mock.patch( + "sys.stdout", buf + ): + with pytest.raises(KeyboardInterrupt): + with spinner("Robust teardown"): + pass + + output = buf.getvalue() + assert "\033[2K\r" in output, ( + "Line-clear escape sequence must be written before join() is called" + ) From 86fac864a06ddb4a291639acc02151d00017b3e5 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:13:23 -0400 Subject: [PATCH 25/51] fix(ui): clear spinner line after joining its thread Clearing before the join left a race in the normal path: a spinner thread sitting just past its stop_event.is_set() check could repaint the line after the erase, leaving the terminal dirty. Join first so no further writes are possible, and nest the erase in a finally so an interrupted join (hate_crack raises DoubleInterrupt on a second SIGINT) still leaves a clean line. Co-Authored-By: Claude --- hate_crack/progress.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/hate_crack/progress.py b/hate_crack/progress.py index 0bb33a6..cc63d54 100644 --- a/hate_crack/progress.py +++ b/hate_crack/progress.py @@ -59,12 +59,13 @@ def spinner(message: str) -> "Generator[None, None, None]": yield finally: stop_event.set() - # Clear the spinner line *before* joining so the terminal is left clean - # even if join() is interrupted by a second Ctrl-C (DoubleInterrupt). - # hate_crack's _sigint_handler raises DoubleInterrupt on a second SIGINT - # within 2 s, and join() can block up to _TICK_INTERVAL (0.12 s) — a - # narrow but real window. Writing the escape sequence first ensures the - # line is always erased regardless of what happens to the join. - sys.stdout.write("\033[2K\r") - sys.stdout.flush() - thread.join() + # Clear *after* the join, so a thread sitting just past its is_set() + # check cannot repaint the line after we erase it. The nested finally + # keeps that guarantee even when join() is interrupted: hate_crack's + # _sigint_handler raises DoubleInterrupt on a second SIGINT within 2 s, + # and join() can block up to _TICK_INTERVAL (0.12 s). + try: + thread.join() + finally: + sys.stdout.write("\033[2K\r") + sys.stdout.flush() From 39e0dd956a216009cf7806eb12dd46c787fe4ab1 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:18:52 -0400 Subject: [PATCH 26/51] feat(llm): add cracked-password generation mode to the LLM attack Adds a third LLM Attack generation mode ("cracked") that samples the plaintexts already recovered this session from .out and asks the model to infer the target organization's password conventions and emit new candidates in the same style. - llm.py: new _CRACKED_PROMPT (offensive candidate generation, explicitly not the denylist-oriented _WORDLIST_PROMPT), a _PROMPTS mode->prompt map, and a "cracked" branch in _build_request that tells the model not to repeat already-cracked passwords. - main.py: extract the wordlist sampling logic into the shared module-level helper _sample_plaintext_file(path, cap, source_label) and call it from both the wordlist and cracked branches of hcatOllama; guard missing and empty .out files. - attacks.py: offer mode 3 only when .out exists and is non-empty (matching _markov_pick_training_source), with a clear message otherwise. - README: document the three modes and the widened ollamaMaxSampleLines scope. Co-Authored-By: Claude --- README.md | 9 +- hate_crack/attacks.py | 10 ++ hate_crack/llm.py | 42 +++++++- hate_crack/main.py | 141 ++++++++++++++++--------- tests/test_attacks_behavior.py | 62 +++++++++++ tests/test_hcat_ollama.py | 86 +++++++++++++++ tests/test_llm.py | 65 ++++++++++++ tests/test_ollama_wordlist_sampling.py | 86 +++++++++++++++ 8 files changed, 446 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index 5749108..6b10e8c 100644 --- a/README.md +++ b/README.md @@ -472,10 +472,14 @@ The LLM Attack (option 12) uses Ollama to generate password candidates. Configur - **`ollamaModel`** — The Ollama model used for candidate generation (default: `qwen2.5:32b`). The LLM attack uses structured (JSON) output, so choose a model with good tool/JSON support. - **`ollamaNumCtx`** — Context window size for the model (default: `2048`). - **`ollamaTimeout`** — Seconds to wait for a generation response before giving up (default: `300`). Raise this if a large model is still loading into VRAM on the first request, which can otherwise exceed the timeout; hate_crack prints the elapsed timeout and this setting's name when it fires. -- **`ollamaMaxSampleLines`** — Maximum number of lines drawn from the source wordlist and included in the LLM prompt when using **Wordlist** mode (default: `500`). Lines are sampled evenly across the whole file so the prompt reflects the wordlist's full character range rather than just the head. Set to a larger value if the model has a big context window (`ollamaNumCtx`) and you want richer coverage; set it lower to reduce prompt size and generation latency. Values ≤ 0 are treated as 500. +- **`ollamaMaxSampleLines`** — Maximum number of lines drawn from the source file and included in the LLM prompt when using **Wordlist** or **Cracked passwords** mode (default: `500`). Lines are sampled evenly across the whole file so the prompt reflects the wordlist's full character range rather than just the head. Set to a larger value if the model has a big context window (`ollamaNumCtx`) and you want richer coverage; set it lower to reduce prompt size and generation latency. Values ≤ 0 are treated as 500. - The Ollama URL defaults to `http://localhost:11434` (override via the `OLLAMA_HOST` env var). Ensure Ollama is running and the model is pulled (`ollama pull qwen2.5:32b`) before using the LLM Attack — hate_crack no longer auto-pulls missing models. -The attack offers two generation modes: **Target info** (company / industry / location) and **Wordlist** (derive denylist basewords from a sample wordlist). +The attack offers three generation modes: + +1. **Target info** — company / industry / location; the model derives candidates from those details. +2. **Wordlist** — derive basewords from a sample wordlist. +3. **Cracked passwords** — feed the plaintexts already recovered this session (`.out`) back to the model so it can infer the target organization's own password conventions (basewords, seasons, years, suffixes, leetspeak) and generate *new* candidates in the same style. This option is only listed once at least one hash has been cracked; the sample is capped by `ollamaMaxSampleLines` exactly like Wordlist mode. ### Notifications (menu option 82) @@ -825,6 +829,7 @@ Uses a local Ollama instance to generate password candidates for a capture-the-f * Requires a running Ollama instance (default: `http://localhost:11434`) * Configurable model and context window via `config.json` (see Ollama Configuration below) * Prompts for target company name, industry, and location +* Alternatively derives basewords from a sample **wordlist**, or from the **cracked passwords** of the current session (`.out`) so the model mirrors the target organization's own password conventions and produces new candidates in that style (only offered once something has been cracked) #### OMEN Attack Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model from a wordlist and generate password candidates. This attack learns patterns from known passwords and generates new candidates based on those patterns. diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index b63b17c..c6252e2 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -515,6 +515,12 @@ def ollama_attack(ctx: Any) -> None: print("\n\tLLM Attack") print("\t1. Target info (company / industry / location)") print("\t2. Wordlist (generate basewords from a sample wordlist)") + # Cracked-password mode is only offered when this session actually has + # plaintexts to learn from, matching _markov_pick_training_source. + out_path = f"{ctx.hcatHashFile}.out" + has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 + if has_cracked: + print("\t3. Cracked passwords (current session)") choice = input("\n\tSelect generation mode: ").strip() if choice == "1": @@ -532,6 +538,10 @@ def ollama_attack(ctx: Any) -> None: if not path: return ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) + elif choice == "3" and has_cracked: + ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path) + elif choice == "3": + print("\t[!] No cracked passwords yet — crack some hashes first.") else: print("\t[!] Invalid selection.") diff --git a/hate_crack/llm.py b/hate_crack/llm.py index 184d4b0..f4cf5f5 100644 --- a/hate_crack/llm.py +++ b/hate_crack/llm.py @@ -74,6 +74,37 @@ _WORDLIST_PROMPT = SystemPromptGenerator( ], ) +_CRACKED_PROMPT = SystemPromptGenerator( + background=[ + "You are a security professional generating password candidates during an " + "authorized penetration test.", + "The passwords you are shown were already recovered from this specific " + "target organization, so they reveal that organization's real password " + "conventions.", + ], + steps=[ + "Study the recovered plaintexts for the organization's conventions: " + "basewords, capitalization, seasons and months, years, separators, " + "suffixes, and leetspeak substitutions.", + "Infer the naming habits behind them (company and product names, local " + "sports teams, site or department names, keyboard walks).", + "Generate NEW candidates that follow the same conventions, varying the " + "basewords, years, and suffixes the organization clearly favours.", + ], + output_instructions=[ + "Return only candidate passwords in the candidates list.", + "Do not repeat any password that appears in the input — those are already " + "cracked and retrying them is wasted work.", + "Do not include explanations, numbering, or duplicate entries.", + ], +) + +_PROMPTS = { + "target": _TARGET_PROMPT, + "wordlist": _WORDLIST_PROMPT, + "cracked": _CRACKED_PROMPT, +} + def _build_request(mode: str, context_data: dict) -> str: """Build the natural-language request string for the given mode.""" @@ -93,6 +124,14 @@ def _build_request(mode: str, context_data: dict) -> str: "Here are sample passwords. Study their patterns and generate basewords " "for a denylist:\n" + sample ) + if mode == "cracked": + sample = context_data.get("sample", "") + return ( + "These passwords were already cracked from the target organization. " + "Study the conventions they reveal and generate as many NEW password " + "candidates as you can that follow the same conventions. Do not repeat " + "any of these:\n" + sample + ) raise ValueError(f"Unknown LLM generation mode: {mode}") @@ -121,7 +160,8 @@ def generate_candidates( OpenAI(base_url=f"{url}/v1", api_key="ollama", timeout=timeout), mode=instructor.Mode.JSON, ) - prompt_generator = _TARGET_PROMPT if mode == "target" else _WORDLIST_PROMPT + # _build_request has already rejected unknown modes, so this lookup is safe. + prompt_generator = _PROMPTS[mode] agent = AtomicAgent[GenerationInput, PasswordCandidatesOutput]( config=AgentConfig( diff --git a/hate_crack/main.py b/hate_crack/main.py index 2cce2d2..b0818eb 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -2055,6 +2055,74 @@ def hcatBandrel(hcatHashType, hcatHashFile): _run_hcat_cmd(cmd, attack_name="Bandrel", hash_file=hcatHashFile) +def _sample_plaintext_file(path, cap, source_label="wordlist"): + """Return an evenly-spaced sample of usable plaintexts from ``path``. + + ``cap`` is the maximum number of lines to keep (values <= 0 fall back to the + built-in default of 500). ``source_label`` is used only in the progress and + error messages so callers can say "wordlist" or "cracked passwords". + + Returns a list of plaintexts (possibly empty when the file has no usable + lines), or ``None`` if the file could not be read — in which case an error + has already been printed. + """ + # Two-pass evenly-spaced sample: first count usable lines so we can + # stride-select across the whole file rather than taking a head slice. + # A head-only sample misses the pattern variation across large wordlists + # (e.g. rockyou.txt becomes more random further in). + try: + total_usable = 0 + with open(path, "r", errors="ignore") as f: + for raw in f: + if _usable_plaintext(raw): + total_usable += 1 + except Exception as e: + print(f"Error reading {source_label}: {e}") + return None + + # Invalid cap (zero or negative): fall back to the built-in default of 500. + if cap <= 0: + cap = 500 + + if total_usable <= cap: + # No capping needed — collect all usable lines. + try: + sampled: list[str] = [] + with open(path, "r", errors="ignore") as f: + for raw in f: + w = _usable_plaintext(raw) + if w: + sampled.append(w) + except Exception as e: + print(f"Error reading {source_label}: {e}") + return None + print(f"Loaded {len(sampled):,} passwords from {source_label}.") + return sampled + + # Evenly-spaced sample: the k-th pick targets index floor(k * total / cap), + # which yields EXACTLY cap distinct indices spanning the full range for any + # 1 <= cap <= total_usable. + try: + pick_set = {(k * total_usable) // cap for k in range(cap)} + sampled = [] + usable_idx = 0 + with open(path, "r", errors="ignore") as f: + for raw in f: + w = _usable_plaintext(raw) + if not w: + continue + if usable_idx in pick_set: + sampled.append(w) + usable_idx += 1 + except Exception as e: + print(f"Error reading {source_label}: {e}") + return None + print( + f"Sampled {len(sampled):,} of {total_usable:,} passwords from {source_label}." + ) + return sampled + + # LLM Ollama Attack def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): candidates_path = f"{hcatHashFile}.ollama_candidates" @@ -2066,60 +2134,29 @@ def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): print(f"Error: Wordlist not found: {wordlist_path}") return - # Two-pass evenly-spaced sample: first count usable lines so we can - # stride-select across the whole file rather than taking a head slice. - # A head-only sample misses the pattern variation across large wordlists - # (e.g. rockyou.txt becomes more random further in). - try: - total_usable = 0 - with open(wordlist_path, "r", errors="ignore") as f: - for raw in f: - if _usable_plaintext(raw): - total_usable += 1 - except Exception as e: - print(f"Error reading wordlist: {e}") + sampled = _sample_plaintext_file(wordlist_path, ollamaMaxSampleLines) + if sampled is None: + return + gen_context = {"sample": "\n".join(sampled)} + elif mode == "cracked": + # context_data may carry an explicit path; default to this session's + # cracked-output file. + cracked_path = context_data or f"{hcatHashFile}.out" + if not os.path.isfile(cracked_path): + print(f"Error: No cracked passwords found: {cracked_path}") return - cap = ollamaMaxSampleLines - # Invalid cap (zero or negative): fall back to the built-in default of 500. - if cap <= 0: - cap = 500 - if total_usable <= cap: - # No capping needed — collect all usable lines. - try: - sampled: list[str] = [] - with open(wordlist_path, "r", errors="ignore") as f: - for raw in f: - w = _usable_plaintext(raw) - if w: - sampled.append(w) - except Exception as e: - print(f"Error reading wordlist: {e}") - return - print(f"Loaded {len(sampled):,} passwords from wordlist.") - else: - # Evenly-spaced sample: the k-th pick targets index floor(k * total / cap), - # which yields EXACTLY cap distinct indices spanning the full range for any - # 1 <= cap <= total_usable. - try: - pick_set = { - (k * total_usable) // cap for k in range(cap) - } - sampled = [] - usable_idx = 0 - with open(wordlist_path, "r", errors="ignore") as f: - for raw in f: - w = _usable_plaintext(raw) - if not w: - continue - if usable_idx in pick_set: - sampled.append(w) - usable_idx += 1 - except Exception as e: - print(f"Error reading wordlist: {e}") - return - print(f"Sampled {len(sampled):,} of {total_usable:,} passwords from wordlist.") - + sampled = _sample_plaintext_file( + cracked_path, ollamaMaxSampleLines, source_label="cracked passwords" + ) + if sampled is None: + return + if not sampled: + print( + "Error: No cracked passwords yet — crack some hashes first, then " + "use this mode to generate more candidates in the same style." + ) + return gen_context = {"sample": "\n".join(sampled)} elif mode == "target": gen_context = context_data diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index 2ce1bab..edee3aa 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -394,3 +394,65 @@ class TestOllamaAttack: with patch("builtins.input", side_effect=["2", "nonsense"]): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() + + def test_cracked_mode_offered_when_out_file_has_content( + self, tmp_path: Path, capsys + ) -> None: + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + out_file = tmp_path / "hashes.txt.out" + out_file.write_text("hash:Summer2024!\n") + ctx = _make_ctx(hash_file=str(hash_file)) + + with patch("builtins.input", side_effect=["3"]): + ollama_attack(ctx) + + assert "3. Cracked passwords" in capsys.readouterr().out + ctx.hcatOllama.assert_called_once_with( + ctx.hcatHashType, str(hash_file), "cracked", str(out_file) + ) + + def test_cracked_mode_not_offered_when_out_file_missing( + self, tmp_path: Path, capsys + ) -> None: + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + ctx = _make_ctx(hash_file=str(hash_file)) + + with patch("builtins.input", side_effect=["3"]): + ollama_attack(ctx) + + out = capsys.readouterr().out + assert "3. Cracked passwords" not in out + assert "No cracked passwords yet" in out + ctx.hcatOllama.assert_not_called() + + def test_cracked_mode_not_offered_when_out_file_empty( + self, tmp_path: Path, capsys + ) -> None: + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + (tmp_path / "hashes.txt.out").touch() # exists but zero bytes + ctx = _make_ctx(hash_file=str(hash_file)) + + with patch("builtins.input", side_effect=["3"]): + ollama_attack(ctx) + + out = capsys.readouterr().out + assert "3. Cracked passwords" not in out + assert "No cracked passwords yet" in out + ctx.hcatOllama.assert_not_called() + + def test_target_and_wordlist_modes_unaffected_by_cracked_option( + self, tmp_path: Path + ) -> None: + """Existing modes still work when a cracked file is present.""" + hash_file = tmp_path / "hashes.txt" + hash_file.touch() + (tmp_path / "hashes.txt.out").write_text("hash:Summer2024!\n") + ctx = _make_ctx(hash_file=str(hash_file)) + + with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]): + ollama_attack(ctx) + + assert ctx.hcatOllama.call_args[0][2] == "target" diff --git a/tests/test_hcat_ollama.py b/tests/test_hcat_ollama.py index 7786535..95b0a5a 100644 --- a/tests/test_hcat_ollama.py +++ b/tests/test_hcat_ollama.py @@ -228,3 +228,89 @@ def test_unknown_mode_prints_error(ollama_env, capsys): captured = capsys.readouterr() assert "Unknown LLM generation mode" in captured.out gen.assert_not_called() + + +# --------------------------------------------------------------------------- +# cracked mode +# --------------------------------------------------------------------------- + + +def test_cracked_mode_samples_out_file(ollama_env): + """cracked mode reads .out and passes the plaintexts as the sample.""" + with open(f"{ollama_env.hash_file}.out", "w") as f: + f.write("aad3b435:Summer2024!\nbbccddee:Acme2023\n") + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Winter2025!"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", None) + + args = gen.call_args[0] + assert args[3] == "cracked" + sample = args[4]["sample"].splitlines() + assert sample == ["Summer2024!", "Acme2023"] + # Hash portions must not leak into the prompt. + assert "aad3b435" not in args[4]["sample"] + + +def test_cracked_mode_accepts_explicit_path(ollama_env): + """An explicit path in context_data is honoured (what attacks.py passes).""" + out_path = f"{ollama_env.hash_file}.out" + with open(out_path, "w") as f: + f.write("hash:Falcons2024\n") + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Falcons2025"]) as gen, \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", out_path) + + assert "Falcons2024" in gen.call_args[0][4]["sample"] + + +def test_cracked_mode_missing_out_file_prints_error(ollama_env, capsys): + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates") as gen, \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "cracked", None) + captured = capsys.readouterr() + assert "No cracked passwords found" in captured.out + gen.assert_not_called() + popen.assert_not_called() + + +def test_cracked_mode_empty_out_file_prints_error(ollama_env, capsys): + """An existing but empty/blank .out must abort before calling the LLM.""" + with open(f"{ollama_env.hash_file}.out", "w") as f: + f.write("\n \n") + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates") as gen, \ + mock.patch("subprocess.Popen") as popen: + hc_main.hcatOllama("0", ollama_env.hash_file, "cracked", None) + captured = capsys.readouterr() + assert "No cracked passwords yet" in captured.out + gen.assert_not_called() + popen.assert_not_called() + + +def test_cracked_mode_writes_candidates_to_separate_file(ollama_env): + """The candidate file must be distinct from the .out file it samples.""" + out_path = f"{ollama_env.hash_file}.out" + with open(out_path, "w") as f: + f.write("hash:Summer2024!\n") + + with ollama_globals(ollama_env.tmp_path), \ + mock.patch("hate_crack.main.llm.generate_candidates", + return_value=["Winter2025!"]), \ + mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("1000", ollama_env.hash_file, "cracked", None) + + candidates_path = f"{ollama_env.hash_file}.ollama_candidates" + assert candidates_path != out_path + with open(candidates_path) as f: + assert f.read().splitlines() == ["Winter2025!"] + # The sampled source file is untouched by candidate writing. + with open(out_path) as f: + assert f.read() == "hash:Summer2024!\n" diff --git a/tests/test_llm.py b/tests/test_llm.py index f34b3b8..70c58ed 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -70,6 +70,71 @@ def test_wordlist_mode_includes_sample_in_request(): assert "letmein" in run_arg.request +def test_cracked_mode_includes_sample_in_request(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["Winter2025!"]) + with p_instr, p_openai, p_agent: + out = llm.generate_candidates( + "http://localhost:11434", + "qwen2.5:32b", + 2048, + "cracked", + {"sample": "Summer2024!\nAcme2023\nP@ssw0rd1"}, + ) + assert out == ["Winter2025!"] + run_arg = agent_instance.run.call_args[0][0] + assert "Acme2023" in run_arg.request + # The request must tell the model not to regenerate what is already cracked. + assert "NEW" in run_arg.request + assert "Do not repeat" in run_arg.request + + +def test_cracked_mode_uses_its_own_prompt_not_the_denylist_one(): + """cracked mode must select _CRACKED_PROMPT, never the denylist _WORDLIST_PROMPT.""" + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "cracked", {"sample": "Summer2024!"}, + ) + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.system_prompt_generator is llm._CRACKED_PROMPT + assert config.system_prompt_generator is not llm._WORDLIST_PROMPT + + +def test_wordlist_mode_still_uses_wordlist_prompt(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "wordlist", {"sample": "password"}, + ) + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.system_prompt_generator is llm._WORDLIST_PROMPT + + +def test_target_mode_still_uses_target_prompt(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent(["x"]) + with p_instr, p_openai, p_agent: + llm.generate_candidates( + "http://localhost:11434", "qwen2.5:32b", 2048, + "target", {"company": "X", "industry": "Y", "location": "Z"}, + ) + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.system_prompt_generator is llm._TARGET_PROMPT + + +def test_cracked_prompt_is_offensive_not_denylist(): + """_CRACKED_PROMPT's objective is candidate generation, not denylist building.""" + rendered = llm._CRACKED_PROMPT.generate_prompt() + assert "denylist" not in rendered.lower() + assert "authorized penetration test" in rendered.lower() + assert "already recovered" in rendered.lower() + + +def test_prompts_map_covers_every_supported_mode(): + assert set(llm._PROMPTS) == {"target", "wordlist", "cracked"} + + def test_dedupes_and_caps_length(): long_pw = "A" * 129 p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_agent( diff --git a/tests/test_ollama_wordlist_sampling.py b/tests/test_ollama_wordlist_sampling.py index b123247..d4e193a 100644 --- a/tests/test_ollama_wordlist_sampling.py +++ b/tests/test_ollama_wordlist_sampling.py @@ -373,3 +373,89 @@ def test_usable_plaintext_multiple_colons(): def test_usable_plaintext_hash_colon_empty(): """A hash: line with no plaintext after the colon returns empty string.""" assert hc_main._usable_plaintext("aabbcc:") == "" + + +# --------------------------------------------------------------------------- +# Shared sampling helper — used by both wordlist and cracked modes +# --------------------------------------------------------------------------- + + +def test_sample_helper_is_module_level(): + """The sampling logic lives in one shared, module-level helper.""" + assert callable(hc_main._sample_plaintext_file) + + +def test_sample_helper_caps_and_labels_source(tmp_path, capsys): + src = tmp_path / "src.txt" + src.write_text("\n".join(f"p{i:04d}" for i in range(100)) + "\n") + + sampled = hc_main._sample_plaintext_file(str(src), 10, source_label="cracked passwords") + + assert sampled is not None + assert len(sampled) == 10 + captured = capsys.readouterr() + assert "Sampled 10 of 100 passwords from cracked passwords." in captured.out + + +def test_sample_helper_returns_none_on_read_error(tmp_path, capsys): + missing = tmp_path / "nope.txt" + with mock.patch("builtins.open", side_effect=OSError("boom")): + assert hc_main._sample_plaintext_file(str(missing), 10) is None + assert "Error reading wordlist: boom" in capsys.readouterr().out + + +def test_sample_helper_empty_file_returns_empty_list(tmp_path): + src = tmp_path / "empty.txt" + src.write_text("\n\n") + assert hc_main._sample_plaintext_file(str(src), 10) == [] + + +def test_cracked_mode_uses_shared_sampling_helper(env): + """cracked mode must route through _sample_plaintext_file, not its own copy.""" + out_path = env.hash_file + ".out" + with open(out_path, "w") as f: + f.write("hash:Summer2024!\n") + + with _ollama_globals(env.tmp_path, max_sample=123), mock.patch.object( + hc_main, "_sample_plaintext_file", return_value=["Summer2024!"] + ) as sampler, mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["Winter2025!"] + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "cracked", None) + + sampler.assert_called_once_with( + out_path, 123, source_label="cracked passwords" + ) + + +def test_wordlist_mode_uses_shared_sampling_helper(env): + wl = env.tmp_path / "wl.txt" + wl.write_text("alpha\n") + + with _ollama_globals(env.tmp_path, max_sample=77), mock.patch.object( + hc_main, "_sample_plaintext_file", return_value=["alpha"] + ) as sampler, mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ), mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "wordlist", str(wl)) + + sampler.assert_called_once_with(str(wl), 77) + + +def test_cracked_mode_caps_large_out_file(env, capsys): + """A large .out file gets the same evenly-spaced capping as a wordlist.""" + out_path = env.hash_file + ".out" + with open(out_path, "w") as f: + f.write("\n".join(f"h{i}:pw{i:06d}" for i in range(1000)) + "\n") + + with _ollama_globals(env.tmp_path, max_sample=25), mock.patch.object( + hc_main.llm, "generate_candidates", return_value=["x"] + ) as gen, mock.patch("subprocess.Popen", return_value=_make_proc()): + hc_main.hcatOllama("0", env.hash_file, "cracked", None) + + sample_lines = gen.call_args[0][4]["sample"].splitlines() + assert len(sample_lines) == 25 + indices = [int(w.replace("pw", "")) for w in sample_lines] + assert min(indices) < 100 + assert max(indices) >= 900 + assert "Sampled 25 of 1,000 passwords from cracked passwords." in capsys.readouterr().out From 07ca6ba7e34857f7e1fc82b528d9aef920e4787c Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:31:04 -0400 Subject: [PATCH 27/51] fix(ui): route ollama/omen submenus through interactive_menu; re-prompt pickers on invalid input - ollama_attack: convert generation-mode menu to interactive_menu with dynamic items list (option 3 only present when cracked file exists); cancel via 99 or Escape; re-prompts in a while loop - omen_attack: convert existing-model menu to interactive_menu; cancel via 99 or Escape (replaces hard-coded "3. Cancel"); re-prompts in loop - _omen_pick_training_wordlist: replace abort-on-invalid with re-prompt loop; add explicit 'q' cancel key so users can exit without being trapped; callers already handle None correctly - _markov_pick_training_source: same re-prompt-loop + 'q' cancel fix; callers already handle None correctly - Update all affected tests to patch interactive_menu at hate_crack.attacks.interactive_menu (module-level import path) and drive follow-up prompts via builtins.input - Add new tests: arrow-menu reach, Escape/99 cancel, re-prompt loop coverage for both pickers, and conditional option-3 key presence Co-Authored-By: Claude --- hate_crack/attacks.py | 189 ++++++++++++++++-------------- tests/test_attacks_behavior.py | 208 +++++++++++++++++++++++++++++---- tests/test_omen_attack.py | 54 ++++++--- 3 files changed, 326 insertions(+), 125 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index c6252e2..ac4560c 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -512,65 +512,73 @@ def bandrel_method(ctx: Any) -> None: def ollama_attack(ctx: Any) -> None: _notify.prompt_notify_for_attack("LLM") - print("\n\tLLM Attack") - print("\t1. Target info (company / industry / location)") - print("\t2. Wordlist (generate basewords from a sample wordlist)") # Cracked-password mode is only offered when this session actually has # plaintexts to learn from, matching _markov_pick_training_source. out_path = f"{ctx.hcatHashFile}.out" has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 - if has_cracked: - print("\t3. Cracked passwords (current session)") - choice = input("\n\tSelect generation mode: ").strip() - if choice == "1": - company = input("Company name: ").strip() - industry = input("Industry: ").strip() - location = input("Location: ").strip() - ctx.hcatOllama( - ctx.hcatHashType, - ctx.hcatHashFile, - "target", - {"company": company, "industry": industry, "location": location}, - ) - elif choice == "2": - path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists") - if not path: + items: list[tuple[str, str]] = [ + ("1", "Target info (company / industry / location)"), + ("2", "Wordlist (generate basewords from a sample wordlist)"), + ] + if has_cracked: + items.append(("3", "Cracked passwords (current session)")) + items.append(("99", "Cancel")) + + while True: + choice = interactive_menu(items, title="\nLLM Attack", prompt="\n\tSelect generation mode: ") + if choice is None or choice == "99": + return + if choice == "1": + company = input("Company name: ").strip() + industry = input("Industry: ").strip() + location = input("Location: ").strip() + ctx.hcatOllama( + ctx.hcatHashType, + ctx.hcatHashFile, + "target", + {"company": company, "industry": industry, "location": location}, + ) + return + elif choice == "2": + path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists") + if not path: + return + ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) + return + elif choice == "3" and has_cracked: + ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path) return - ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) - elif choice == "3" and has_cracked: - ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path) - elif choice == "3": - print("\t[!] No cracked passwords yet — crack some hashes first.") - else: - print("\t[!] Invalid selection.") def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): - """Show wordlist picker. Returns path or None.""" + """Show wordlist picker. Returns path or None (user cancelled with 'q').""" wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) - if wordlist_files: - entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] - max_len = max((len(e) for e in entries), default=24) - print_multicolumn_list( - title, - entries, - min_col_width=max_len, - max_col_width=max_len, - ) - print("\tp. Enter a custom path") - sel = input("\n\tSelect wordlist: ").strip() - if sel.lower() == "p": - path = input("\n\tPath to wordlist: ").strip() - return path if path else None - try: - idx = int(sel) - if 1 <= idx <= len(wordlist_files): - return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) - except (ValueError, IndexError): - pass - print("\t[!] Invalid selection.") - return None + while True: + if wordlist_files: + entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] + max_len = max((len(e) for e in entries), default=24) + print_multicolumn_list( + title, + entries, + min_col_width=max_len, + max_col_width=max_len, + ) + print("\tp. Enter a custom path") + print("\tq. Cancel") + sel = input("\n\tSelect wordlist: ").strip() + if sel.lower() == "q": + return None + if sel.lower() == "p": + path = input("\n\tPath to wordlist: ").strip() + return path if path else None + try: + idx = int(sel) + if 1 <= idx <= len(wordlist_files): + return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) + except (ValueError, IndexError): + pass + print("\t[!] Invalid selection.") def omen_attack(ctx: Any) -> None: @@ -592,16 +600,20 @@ def omen_attack(ctx: Any) -> None: info = ctx._omen_model_info(model_dir) trained_with = info.get("training_file", "unknown") if info else "unknown" print(f"\n\tOMEN model found (trained with: {trained_with})") - print("\t1. Use existing model") - print("\t2. Train new model (overwrites existing)") - print("\t3. Cancel") - choice = input("\n\tChoice: ").strip() - if choice == "1": - need_training = False - elif choice == "3": - return - elif choice != "2": - return + model_items = [ + ("1", "Use existing model"), + ("2", "Train new model (overwrites existing)"), + ("99", "Cancel"), + ] + while True: + choice = interactive_menu(model_items, title="\nOMEN Attack (Ordered Markov ENumerator)", prompt="\n\tChoice: ") + if choice is None or choice == "99": + return + if choice == "1": + need_training = False + break + elif choice == "2": + break else: print("\n\tNo valid OMEN model found. Training is required.") @@ -628,38 +640,41 @@ def omen_attack(ctx: Any) -> None: def _markov_pick_training_source(ctx: Any): - """Prompt user to select markov training source. Returns file path or None.""" + """Prompt user to select markov training source. Returns file path or None (user cancelled with 'q').""" out_path = f"{ctx.hcatHashFile}.out" has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) - entries = [] - if has_cracked: - entries.append("0) Cracked passwords (current session)") - entries.extend([f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]) - if entries: - max_len = max((len(e) for e in entries), default=24) - print_multicolumn_list( - "Markov Training Source", - entries, - min_col_width=max_len, - max_col_width=max_len, - ) - print("\tp. Enter a custom path") - sel = input("\n\tSelect training source: ").strip() - if sel == "0" and has_cracked: - return out_path - if sel.lower() == "p": - path = input("\n\tPath to training file: ").strip() - return path if path else None - try: - idx = int(sel) - if 1 <= idx <= len(wordlist_files): - return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) - except (ValueError, IndexError): - pass - print("\t[!] Invalid selection.") - return None + while True: + entries = [] + if has_cracked: + entries.append("0) Cracked passwords (current session)") + entries.extend([f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]) + if entries: + max_len = max((len(e) for e in entries), default=24) + print_multicolumn_list( + "Markov Training Source", + entries, + min_col_width=max_len, + max_col_width=max_len, + ) + print("\tp. Enter a custom path") + print("\tq. Cancel") + sel = input("\n\tSelect training source: ").strip() + if sel.lower() == "q": + return None + if sel == "0" and has_cracked: + return out_path + if sel.lower() == "p": + path = input("\n\tPath to training file: ").strip() + return path if path else None + try: + idx = int(sel) + if 1 <= idx <= len(wordlist_files): + return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1]) + except (ValueError, IndexError): + pass + print("\t[!] Invalid selection.") def adhoc_mask_crack(ctx: Any) -> None: diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index edee3aa..ae7186b 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -329,7 +329,10 @@ class TestOllamaAttack: def test_calls_hcatOllama_with_context(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["ACME", "tech", "NYC"]), + ): ollama_attack(ctx) ctx.hcatOllama.assert_called_once_with( @@ -342,7 +345,10 @@ class TestOllamaAttack: def test_passes_hash_type_and_file(self) -> None: ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt") - with patch("builtins.input", side_effect=["1", "Corp", "finance", "London"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["Corp", "finance", "London"]), + ): ollama_attack(ctx) call_args = ctx.hcatOllama.call_args[0] @@ -352,7 +358,10 @@ class TestOllamaAttack: def test_strips_whitespace_from_inputs(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["1", " ACME ", " tech ", " NYC "]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=[" ACME ", " tech ", " NYC "]), + ): ollama_attack(ctx) target_info = ctx.hcatOllama.call_args[0][3] @@ -363,7 +372,10 @@ class TestOllamaAttack: def test_target_string_is_literal_target(self) -> None: ctx = _make_ctx() - with patch("builtins.input", side_effect=["1", "X", "Y", "Z"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["X", "Y", "Z"]), + ): ollama_attack(ctx) assert ctx.hcatOllama.call_args[0][2] == "target" @@ -373,30 +385,44 @@ class TestOllamaAttack: ctx.list_wordlist_files.return_value = ["rockyou.txt"] ctx.hcatWordlists = "/tmp/wl" - # mode "2", then pick wordlist "1" - with patch("builtins.input", side_effect=["2", "1"]): + # mode "2" from interactive_menu, then pick wordlist "1" via input + with ( + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["1"]), + ): ollama_attack(ctx) args = ctx.hcatOllama.call_args[0] assert args[2] == "wordlist" assert args[3].endswith("rockyou.txt") - def test_invalid_mode_does_not_call_hcatOllama(self) -> None: + def test_escape_cancels_without_calling_hcatOllama(self) -> None: + """None from interactive_menu (Escape / 99) cancels the attack.""" ctx = _make_ctx() - with patch("builtins.input", side_effect=["9"]): + with patch("hate_crack.attacks.interactive_menu", return_value=None): + ollama_attack(ctx) + ctx.hcatOllama.assert_not_called() + + def test_cancel_key_cancels_without_calling_hcatOllama(self) -> None: + """Selecting '99' (Cancel) cancels the attack.""" + ctx = _make_ctx() + with patch("hate_crack.attacks.interactive_menu", return_value="99"): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() def test_wordlist_mode_aborts_when_no_wordlist_picked(self) -> None: ctx = _make_ctx() - # mode "2", then an invalid picker selection -> picker returns None + # mode "2" from interactive_menu, then user cancels the file picker ctx.list_wordlist_files.return_value = [] - with patch("builtins.input", side_effect=["2", "nonsense"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["q"]), + ): ollama_attack(ctx) ctx.hcatOllama.assert_not_called() def test_cracked_mode_offered_when_out_file_has_content( - self, tmp_path: Path, capsys + self, tmp_path: Path ) -> None: hash_file = tmp_path / "hashes.txt" hash_file.touch() @@ -404,43 +430,63 @@ class TestOllamaAttack: out_file.write_text("hash:Summer2024!\n") ctx = _make_ctx(hash_file=str(hash_file)) - with patch("builtins.input", side_effect=["3"]): + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "3" + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) - assert "3. Cracked passwords" in capsys.readouterr().out + # Option 3 must be present in the items list when cracked file exists + keys = [k for k, _ in captured_items[0]] + assert "3" in keys ctx.hcatOllama.assert_called_once_with( ctx.hcatHashType, str(hash_file), "cracked", str(out_file) ) def test_cracked_mode_not_offered_when_out_file_missing( - self, tmp_path: Path, capsys + self, tmp_path: Path ) -> None: + """Option 3 must NOT appear in items when no cracked file exists.""" hash_file = tmp_path / "hashes.txt" hash_file.touch() ctx = _make_ctx(hash_file=str(hash_file)) - with patch("builtins.input", side_effect=["3"]): + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "99" # cancel + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) - out = capsys.readouterr().out - assert "3. Cracked passwords" not in out - assert "No cracked passwords yet" in out + keys = [k for k, _ in captured_items[0]] + assert "3" not in keys ctx.hcatOllama.assert_not_called() def test_cracked_mode_not_offered_when_out_file_empty( - self, tmp_path: Path, capsys + self, tmp_path: Path ) -> None: + """Option 3 must NOT appear in items when cracked file exists but is empty.""" hash_file = tmp_path / "hashes.txt" hash_file.touch() (tmp_path / "hashes.txt.out").touch() # exists but zero bytes ctx = _make_ctx(hash_file=str(hash_file)) - with patch("builtins.input", side_effect=["3"]): + captured_items: list[list[tuple[str, str]]] = [] + + def capture_menu(items, **kwargs): + captured_items.append(list(items)) + return "99" # cancel + + with patch("hate_crack.attacks.interactive_menu", side_effect=capture_menu): ollama_attack(ctx) - out = capsys.readouterr().out - assert "3. Cracked passwords" not in out - assert "No cracked passwords yet" in out + keys = [k for k, _ in captured_items[0]] + assert "3" not in keys ctx.hcatOllama.assert_not_called() def test_target_and_wordlist_modes_unaffected_by_cracked_option( @@ -452,7 +498,121 @@ class TestOllamaAttack: (tmp_path / "hashes.txt.out").write_text("hash:Summer2024!\n") ctx = _make_ctx(hash_file=str(hash_file)) - with patch("builtins.input", side_effect=["1", "ACME", "tech", "NYC"]): + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["ACME", "tech", "NYC"]), + ): ollama_attack(ctx) assert ctx.hcatOllama.call_args[0][2] == "target" + + def test_arrow_menu_env_reaches_ollama_attack(self) -> None: + """HATE_CRACK_ARROW_MENU=1 routes through interactive_menu in ollama_attack.""" + import os + from hate_crack.attacks import interactive_menu as real_im + + ctx = _make_ctx() + calls: list[tuple] = [] + + def spy_menu(items, **kwargs): + calls.append(tuple(items)) + return "99" # cancel immediately + + with ( + patch("hate_crack.attacks.interactive_menu", side_effect=spy_menu), + ): + ollama_attack(ctx) + + # interactive_menu was called — confirms the arrow-menu code path is reachable + assert len(calls) == 1 + keys = [k for k, _ in calls[0]] + assert "1" in keys + assert "2" in keys + assert "99" in keys + + +class TestOmenPickTrainingWordlistReprompt: + """_omen_pick_training_wordlist re-prompts on invalid input instead of aborting.""" + + def _make_ctx(self, wordlist_files=None): + ctx = MagicMock() + ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"] + ctx.hcatWordlists = "/tmp/wl" + ctx.hcatHashFile = "/tmp/hashes.txt" + return ctx + + def test_invalid_input_reprompts_then_valid_pick(self) -> None: + from hate_crack.attacks import _omen_pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + # First input is invalid, second is valid + with patch("builtins.input", side_effect=["bad", "1"]): + result = _omen_pick_training_wordlist(ctx) + assert result is not None + assert "rockyou.txt" in result + + def test_cancel_with_q_returns_none(self) -> None: + from hate_crack.attacks import _omen_pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + with patch("builtins.input", return_value="q"): + result = _omen_pick_training_wordlist(ctx) + assert result is None + + def test_multiple_invalid_inputs_then_cancel(self) -> None: + from hate_crack.attacks import _omen_pick_training_wordlist + + ctx = self._make_ctx(["rockyou.txt"]) + with patch("builtins.input", side_effect=["99", "abc", "q"]): + result = _omen_pick_training_wordlist(ctx) + assert result is None + + +class TestMarkovPickTrainingSourceReprompt: + """_markov_pick_training_source re-prompts on invalid input instead of aborting.""" + + def _make_ctx(self, tmp_path, has_cracked=False, wordlist_files=None): + ctx = MagicMock() + hash_file = str(tmp_path / "hashes.txt") + ctx.hcatHashFile = hash_file + ctx.list_wordlist_files.return_value = wordlist_files or ["rockyou.txt"] + ctx.hcatWordlists = str(tmp_path / "wordlists") + if has_cracked: + (tmp_path / "hashes.txt.out").write_text("cracked_pw\n") + return ctx + + def test_invalid_input_reprompts_then_valid_pick(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + with patch("builtins.input", side_effect=["bad", "1"]): + result = _markov_pick_training_source(ctx) + assert result is not None + assert "rockyou.txt" in result + + def test_cancel_with_q_returns_none(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path) + with patch("builtins.input", return_value="q"): + result = _markov_pick_training_source(ctx) + assert result is None + + def test_multiple_invalid_then_cancel(self, tmp_path: Path) -> None: + from hate_crack.attacks import _markov_pick_training_source + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + with patch("builtins.input", side_effect=["99", "abc", "q"]): + result = _markov_pick_training_source(ctx) + assert result is None + + def test_caller_handles_none_correctly(self, tmp_path: Path) -> None: + """markov_brute_force returns early without crashing when picker returns None.""" + from hate_crack.attacks import markov_brute_force + + ctx = self._make_ctx(tmp_path, wordlist_files=["rockyou.txt"]) + # No .hcstat2 file → goes straight to picker; user cancels + with patch("builtins.input", return_value="q"): + markov_brute_force(ctx) + ctx.hcatMarkovTrain.assert_not_called() + ctx.hcatMarkovBruteForce.assert_not_called() diff --git a/tests/test_omen_attack.py b/tests/test_omen_attack.py index ef33507..cc61f9e 100644 --- a/tests/test_omen_attack.py +++ b/tests/test_omen_attack.py @@ -277,8 +277,10 @@ class TestOmenAttackHandler: def test_use_existing_model(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "0"]), ): from hate_crack.attacks import omen_attack @@ -289,8 +291,10 @@ class TestOmenAttackHandler: def test_train_new_model_with_wordlist_pick(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["2", "1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="2"), + patch("builtins.input", side_effect=["1", "", "0"]), ): from hate_crack.attacks import omen_attack @@ -302,8 +306,22 @@ class TestOmenAttackHandler: def test_cancel_aborts(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["3"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="99"), + ): + from hate_crack.attacks import omen_attack + + omen_attack(ctx) + ctx.hcatOmenTrain.assert_not_called() + ctx.hcatOmen.assert_not_called() + + def test_escape_cancels(self, tmp_path): + """None from interactive_menu (Escape) cancels the attack.""" + ctx = self._make_ctx(tmp_path, model_valid=True) + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value=None), ): from hate_crack.attacks import omen_attack @@ -348,8 +366,10 @@ class TestOmenAttackHandler: def test_rules_passed_to_hcatOmen(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "1"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "1"]), ): from hate_crack.attacks import omen_attack @@ -361,8 +381,10 @@ class TestOmenAttackHandler: def test_multiple_rule_chains_spawn_multiple_calls(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule", "dive.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "1,2"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "1,2"]), ): from hate_crack.attacks import omen_attack @@ -372,8 +394,10 @@ class TestOmenAttackHandler: def test_cancel_from_rules_aborts(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "99"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "99"]), ): from hate_crack.attacks import omen_attack @@ -383,8 +407,10 @@ class TestOmenAttackHandler: def test_no_rules_passes_empty_chain(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=True) self._setup_rules_dir(tmp_path, ["best64.rule"]) - with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["1", "", "0"] + with ( + patch("os.path.isfile", return_value=True), + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", side_effect=["", "0"]), ): from hate_crack.attacks import omen_attack From 24e574bc8e487c28c6f30c95ebd06b0692aaf576 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:33:11 -0400 Subject: [PATCH 28/51] fix(ui): print picker grids once and report rejected menu keys The re-prompt loops repainted the whole multicolumn wordlist grid after every typo, burying the error message under dozens of entries. Hoist the grid and the p/q legend out of the loop so only the prompt repeats. ollama_attack's loop also fell through to a silent redraw on an unrecognised key, leaving no way to tell a rejected key from a repainted prompt. Co-Authored-By: Claude --- hate_crack/attacks.py | 58 ++++++++++++++++++++++++------------------- 1 file changed, 33 insertions(+), 25 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index ac4560c..4288d94 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -549,23 +549,30 @@ def ollama_attack(ctx: Any) -> None: elif choice == "3" and has_cracked: ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "cracked", out_path) return + else: + # Without this the menu just silently redraws and the user cannot + # tell a rejected key from a repainted prompt. + print("\t[!] Invalid selection.") def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): """Show wordlist picker. Returns path or None (user cancelled with 'q').""" wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) + # Print the grid once, outside the retry loop: a wordlists directory can + # hold dozens of entries, and repainting the whole thing after every typo + # buries the error message. + if wordlist_files: + entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] + max_len = max((len(e) for e in entries), default=24) + print_multicolumn_list( + title, + entries, + min_col_width=max_len, + max_col_width=max_len, + ) + print("\tp. Enter a custom path") + print("\tq. Cancel") while True: - if wordlist_files: - entries = [f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)] - max_len = max((len(e) for e in entries), default=24) - print_multicolumn_list( - title, - entries, - min_col_width=max_len, - max_col_width=max_len, - ) - print("\tp. Enter a custom path") - print("\tq. Cancel") sel = input("\n\tSelect wordlist: ").strip() if sel.lower() == "q": return None @@ -645,21 +652,22 @@ def _markov_pick_training_source(ctx: Any): has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) + # Print the grid once, outside the retry loop — see _omen_pick_training_wordlist. + entries = [] + if has_cracked: + entries.append("0) Cracked passwords (current session)") + entries.extend([f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]) + if entries: + max_len = max((len(e) for e in entries), default=24) + print_multicolumn_list( + "Markov Training Source", + entries, + min_col_width=max_len, + max_col_width=max_len, + ) + print("\tp. Enter a custom path") + print("\tq. Cancel") while True: - entries = [] - if has_cracked: - entries.append("0) Cracked passwords (current session)") - entries.extend([f"{i}) {f}" for i, f in enumerate(wordlist_files, start=1)]) - if entries: - max_len = max((len(e) for e in entries), default=24) - print_multicolumn_list( - "Markov Training Source", - entries, - min_col_width=max_len, - max_col_width=max_len, - ) - print("\tp. Enter a custom path") - print("\tq. Cancel") sel = input("\n\tSelect training source: ").strip() if sel.lower() == "q": return None From 6448f550eee8cd816dca9c1ab997c363021771cb Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:42:50 -0400 Subject: [PATCH 29/51] feat(llm): pre-fill LLM target industry/location from local model research Target-info mode (menu 12) asked for company, industry, and location as three blank prompts. Once the company name is known the local Ollama model can often supply the other two, so ask it and offer the answers as editable defaults. - llm.research_target(): TargetResearchInput/TargetResearchOutput schemas plus a research SystemPromptGenerator that tells the model to return empty strings when it does not genuinely recognize the organization, so an unknown small client yields blank prompts instead of a confident hallucination. APITimeoutError is translated to LLMTimeoutError like generate_candidates. - clean_research_field(): strips, collapses whitespace, caps at 80 chars, and turns anything non-string into "" so model output cannot be pasted unbounded into a prompt default. - main.hcatOllamaResearchTarget(): runs the call inside the existing spinner and degrades to blank suggestions on timeout or any other failure, so research can never block the attack. - attacks.ollama_attack(): shows suggestions as "Industry (freight rail): " defaults, labelled explicitly as the model's GUESSES rather than OSINT. - New ollamaAutoResearch config toggle (default true) in both config examples. Research uses only the configured local Ollama server; the client name is never sent to a search engine or third-party company-data API. Co-Authored-By: Claude --- README.md | 22 ++ config.json.example | 1 + hate_crack/attacks.py | 47 ++++- hate_crack/config.json.example | 1 + hate_crack/llm.py | 129 +++++++++++- hate_crack/main.py | 38 ++++ tests/test_llm.py | 110 ++++++++++ tests/test_ollama_target_research.py | 301 +++++++++++++++++++++++++++ 8 files changed, 642 insertions(+), 7 deletions(-) create mode 100644 tests/test_ollama_target_research.py diff --git a/README.md b/README.md index 6b10e8c..32e2738 100644 --- a/README.md +++ b/README.md @@ -473,11 +473,33 @@ The LLM Attack (option 12) uses Ollama to generate password candidates. Configur - **`ollamaNumCtx`** — Context window size for the model (default: `2048`). - **`ollamaTimeout`** — Seconds to wait for a generation response before giving up (default: `300`). Raise this if a large model is still loading into VRAM on the first request, which can otherwise exceed the timeout; hate_crack prints the elapsed timeout and this setting's name when it fires. - **`ollamaMaxSampleLines`** — Maximum number of lines drawn from the source file and included in the LLM prompt when using **Wordlist** or **Cracked passwords** mode (default: `500`). Lines are sampled evenly across the whole file so the prompt reflects the wordlist's full character range rather than just the head. Set to a larger value if the model has a big context window (`ollamaNumCtx`) and you want richer coverage; set it lower to reduce prompt size and generation latency. Values ≤ 0 are treated as 500. +- **`ollamaAutoResearch`** — When `true` (default), **Target info** mode asks the local model to suggest the industry and location as soon as you have typed the company name, and offers them as editable prompt defaults. Set to `false` to always get blank prompts (useful with a slow model, since research costs one extra round-trip before the attack starts). - The Ollama URL defaults to `http://localhost:11434` (override via the `OLLAMA_HOST` env var). Ensure Ollama is running and the model is pulled (`ollama pull qwen2.5:32b`) before using the LLM Attack — hate_crack no longer auto-pulls missing models. The attack offers three generation modes: 1. **Target info** — company / industry / location; the model derives candidates from those details. + + After you type the company name, hate_crack asks the same local model what it already knows about that organization and pre-fills the **Industry** and **Location** prompts with the answers, shown in parentheses: + + ``` + Company name: Acme Rail Services + + [!] The values in parentheses below are the local model's GUESSES, not verified OSINT. + Press Enter to accept, or type your own value to override. + Industry (freight rail maintenance): + Location (Omaha, Nebraska): + ``` + + Press Enter to accept a suggestion or type over it. These values are the model's recollection, **not OSINT** — treat them as a starting point, not intelligence about the client. The lookup uses only the local Ollama server, so the client name never leaves the host; there are no web or third-party API calls. If the model does not recognize the organization (the common case for small clients), it returns nothing and you get plain blank prompts: + + ``` + Company name: Acme Rail Services + Industry: + Location: + ``` + + A research failure — timeout, Ollama not running, empty answer — never blocks the attack; it just falls back to blank prompts. Set `ollamaAutoResearch` to `false` to skip research entirely. 2. **Wordlist** — derive basewords from a sample wordlist. 3. **Cracked passwords** — feed the plaintexts already recovered this session (`.out`) back to the model so it can infer the target organization's own password conventions (basewords, seasons, years, suffixes, leetspeak) and generate *new* candidates in the same style. This option is only listed once at least one hash has been cracked; the sample is capped by `ollamaMaxSampleLines` exactly like Wordlist mode. diff --git a/config.json.example b/config.json.example index 86bd196..ec89e9a 100644 --- a/config.json.example +++ b/config.json.example @@ -29,6 +29,7 @@ "ollamaNumCtx": 2048, "ollamaTimeout": 300, "ollamaMaxSampleLines": 500, + "ollamaAutoResearch": true, "omenTrainingList": "rockyou.txt", "omenMaxCandidates": 50000000, "pcfgRuleset": "DEFAULT", diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 4288d94..c84ccf2 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -7,6 +7,7 @@ from typing import Any from hate_crack import notify as _notify from hate_crack.api import download_hashmob_rules from hate_crack.formatting import print_multicolumn_list +from hate_crack.llm import clean_research_field from hate_crack.menu import interactive_menu @@ -510,6 +511,47 @@ def bandrel_method(ctx: Any) -> None: ctx.hcatBandrel(ctx.hcatHashType, ctx.hcatHashFile) +def _research_target_suggestions(ctx: Any, company: str) -> dict[str, str]: + """Ask the local model for industry/location suggestions for *company*. + + Returns a dict of cleaned suggestion strings (values may be ''). Research is + a convenience only, so any failure is swallowed here as well as in + ``hcatOllamaResearchTarget``: the operator still gets blank prompts and the + attack proceeds. + """ + if not company: + return {} + + try: + raw = ctx.hcatOllamaResearchTarget(company) + except Exception as e: + print(f"Note: target research unavailable ({e}) — enter the details manually.") + return {} + + suggestions = {} + if isinstance(raw, dict): + for key in ("industry", "location"): + value = clean_research_field(raw.get(key, "")) + if value: + suggestions[key] = value + + if suggestions: + print( + "\n[!] The values in parentheses below are the local model's GUESSES, " + "not verified OSINT." + ) + print(" Press Enter to accept, or type your own value to override.") + return suggestions + + +def _prompt_with_default(label: str, default: Any) -> str: + """Prompt for *label*, showing *default* in parentheses when there is one.""" + suggestion = clean_research_field(default) + if suggestion: + return input(f"{label} ({suggestion}): ").strip() or suggestion + return input(f"{label}: ").strip() + + def ollama_attack(ctx: Any) -> None: _notify.prompt_notify_for_attack("LLM") # Cracked-password mode is only offered when this session actually has @@ -531,8 +573,9 @@ def ollama_attack(ctx: Any) -> None: return if choice == "1": company = input("Company name: ").strip() - industry = input("Industry: ").strip() - location = input("Location: ").strip() + suggestions = _research_target_suggestions(ctx, company) + industry = _prompt_with_default("Industry", suggestions.get("industry")) + location = _prompt_with_default("Location", suggestions.get("location")) ctx.hcatOllama( ctx.hcatHashType, ctx.hcatHashFile, diff --git a/hate_crack/config.json.example b/hate_crack/config.json.example index 0bd02d7..fe8ffb8 100644 --- a/hate_crack/config.json.example +++ b/hate_crack/config.json.example @@ -26,6 +26,7 @@ "ollamaNumCtx": 2048, "ollamaTimeout": 300, "ollamaMaxSampleLines": 500, + "ollamaAutoResearch": true, "passgptModel": "javirandor/passgpt-10characters", "passgptMaxCandidates": 1000000, "passgptBatchSize": 1024, diff --git a/hate_crack/llm.py b/hate_crack/llm.py index f4cf5f5..9600489 100644 --- a/hate_crack/llm.py +++ b/hate_crack/llm.py @@ -1,7 +1,7 @@ """Structured LLM password-candidate generation via Atomic Agents + Ollama. Isolates the atomic-agents / instructor dependency. The rest of hate_crack talks -to this module only through ``generate_candidates``. +to this module only through ``generate_candidates`` and ``research_target``. """ import instructor @@ -14,6 +14,10 @@ from atomic_agents.context import SystemPromptGenerator MAX_CANDIDATE_LEN = 128 DEFAULT_TIMEOUT_SECONDS = 300.0 +# Researched target fields are pasted into an interactive prompt as an editable +# default, so they must stay short enough to fit on one terminal line. +MAX_RESEARCH_FIELD_LEN = 80 + class LLMTimeoutError(Exception): """The LLM server accepted the request but did not respond in time. @@ -43,6 +47,60 @@ class PasswordCandidatesOutput(BaseIOSchema): ) +class TargetResearchInput(BaseIOSchema): + """The company name to recall industry and location details for.""" + + company: str = Field( + ..., description="The name of the target organization, exactly as typed." + ) + + +class TargetResearchOutput(BaseIOSchema): + """Recalled industry and location for a named organization, or empty strings.""" + + industry: str = Field( + ..., + description=( + "The organization's industry or sector as a short phrase, e.g. " + "'regional healthcare provider' or 'commercial construction'. Return " + "an empty string if you do not actually recognize this organization." + ), + ) + location: str = Field( + ..., + description=( + "The organization's primary location as 'City, State/Country'. Return " + "an empty string if you do not actually recognize this organization." + ), + ) + + +_RESEARCH_PROMPT = SystemPromptGenerator( + background=[ + "You are assisting a security professional on an authorized penetration " + "test who is about to generate password candidates for a named client " + "organization.", + "You have no internet access. You may only answer from what you already " + "know about the organization.", + "Most client organizations are small and will be completely unknown to " + "you. That is the expected case, not a failure.", + ], + steps=[ + "Decide whether you genuinely recognize this specific organization by name.", + "If you do, recall its industry or sector and its primary location.", + "If you do not recognize it, or you are not reasonably confident, do not " + "guess and do not infer anything from the words in the name.", + ], + output_instructions=[ + "Return an empty string for any field you are not reasonably confident " + "about. An empty field is correct and useful; a fabricated one is harmful " + "because the operator may mistake it for real intelligence.", + "Keep each field under 80 characters.", + "Return only the industry and location fields — no explanations, " + "hedging, caveats, or commentary.", + ], +) + _TARGET_PROMPT = SystemPromptGenerator( background=[ "You are a security professional generating password candidates during an " @@ -135,6 +193,70 @@ def _build_request(mode: str, context_data: dict) -> str: raise ValueError(f"Unknown LLM generation mode: {mode}") +def _build_client(url: str, timeout: float) -> instructor.Instructor: + """Build the instructor-wrapped OpenAI client pointed at an Ollama server.""" + return instructor.from_openai( + OpenAI(base_url=f"{url}/v1", api_key="ollama", timeout=timeout), + mode=instructor.Mode.JSON, + ) + + +def clean_research_field(value: object) -> str: + """Strip and length-cap one researched field; return '' for no suggestion. + + Anything that is not a non-empty string after stripping — including a model + that echoed whitespace or an over-long ramble — becomes '' so the caller + falls back to a plain blank prompt instead of pasting model noise into it. + """ + if not isinstance(value, str): + return "" + cleaned = " ".join(value.split()) + if len(cleaned) > MAX_RESEARCH_FIELD_LEN: + cleaned = cleaned[:MAX_RESEARCH_FIELD_LEN].rstrip() + return cleaned + + +def research_target( + url: str, + model: str, + num_ctx: int, + company: str, + timeout: float = DEFAULT_TIMEOUT_SECONDS, +) -> TargetResearchOutput: + """Ask the local model what it already knows about *company*. + + Returns a ``TargetResearchOutput`` whose ``industry`` and ``location`` are + stripped and capped at ``MAX_RESEARCH_FIELD_LEN``; either may be '' when the + model is not confident, which callers must treat as "no suggestion". + + Uses only the configured local Ollama server — no web lookups, so the client + name never leaves the host. Raises LLMTimeoutError if the request exceeds + ``timeout``; other client/connection errors propagate to the caller. + """ + client = _build_client(url, timeout) + + agent = AtomicAgent[TargetResearchInput, TargetResearchOutput]( + config=AgentConfig( + client=client, + model=model, + system_prompt_generator=_RESEARCH_PROMPT, + model_api_parameters={"extra_body": {"options": {"num_ctx": num_ctx}}}, + ) + ) + + try: + result = agent.run(TargetResearchInput(company=company)) + except APITimeoutError as e: + raise LLMTimeoutError( + f"no response from {url} within {timeout:g} seconds" + ) from e + + return TargetResearchOutput( + industry=clean_research_field(getattr(result, "industry", "")), + location=clean_research_field(getattr(result, "location", "")), + ) + + def generate_candidates( url: str, model: str, @@ -156,10 +278,7 @@ def generate_candidates( """ request = _build_request(mode, context_data) - client = instructor.from_openai( - OpenAI(base_url=f"{url}/v1", api_key="ollama", timeout=timeout), - mode=instructor.Mode.JSON, - ) + client = _build_client(url, timeout) # _build_request has already rejected unknown modes, so this lookup is safe. prompt_generator = _PROMPTS[mode] diff --git a/hate_crack/main.py b/hate_crack/main.py index b0818eb..f6dafd0 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -455,6 +455,7 @@ ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b") ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048)) ollamaTimeout = float(config_parser.get("ollamaTimeout", 300)) ollamaMaxSampleLines = int(config_parser.get("ollamaMaxSampleLines", 500)) +ollamaAutoResearch = bool(config_parser.get("ollamaAutoResearch", True)) omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt") omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000)) @@ -2123,6 +2124,43 @@ def _sample_plaintext_file(path, cap, source_label="wordlist"): return sampled +def hcatOllamaResearchTarget(company): + """Ask the local Ollama model what it knows about *company*. + + Returns a dict with "industry" and "location" keys; either value may be an + empty string when the model is not confident or the request failed. Never + raises: research is a convenience, so any failure degrades to empty + suggestions (blank prompts) rather than blocking the attack. + + Uses only the configured local Ollama server — the company name is never + sent to a third-party service. + """ + blank = {"industry": "", "location": ""} + if not ollamaAutoResearch or not company: + return blank + + try: + with spinner(f"Researching {company} via Ollama ({ollamaModel})..."): + result = llm.research_target( + ollamaUrl, + ollamaModel, + ollamaNumCtx, + company, + timeout=ollamaTimeout, + ) + except llm.LLMTimeoutError: + print( + f"Note: target research timed out after {ollamaTimeout:g} seconds — " + "enter the details manually." + ) + return blank + except Exception as e: + print(f"Note: target research unavailable ({e}) — enter the details manually.") + return blank + + return {"industry": result.industry, "location": result.location} + + # LLM Ollama Attack def hcatOllama(hcatHashType, hcatHashFile, mode, context_data): candidates_path = f"{hcatHashFile}.ollama_candidates" diff --git a/tests/test_llm.py b/tests/test_llm.py index 70c58ed..1e8ef94 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -210,3 +210,113 @@ def test_api_timeout_reraised_as_domain_error(): "target", {"company": "X", "industry": "Y", "location": "Z"}, timeout=1.0, ) + + +# --------------------------------------------------------------------------- +# research_target +# --------------------------------------------------------------------------- + + +def _patch_research_agent(industry, location): + """Patch client builders + AtomicAgent for a research call. No network.""" + result = mock.MagicMock() + result.industry = industry + result.location = location + + agent_instance = mock.MagicMock() + agent_instance.run.return_value = result + + agent_cls = mock.MagicMock() + agent_cls.__getitem__.return_value.return_value = agent_instance + + return ( + mock.patch( + "hate_crack.llm.instructor.from_openai", + return_value=mock.MagicMock(spec=instructor.Instructor), + ), + mock.patch("hate_crack.llm.OpenAI"), + mock.patch("hate_crack.llm.AtomicAgent", agent_cls), + agent_cls, + agent_instance, + ) + + +def test_research_target_returns_fields_and_passes_company(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + "freight rail maintenance", "Omaha, Nebraska" + ) + with p_instr, p_openai, p_agent: + out = llm.research_target( + "http://localhost:11434", "qwen2.5:32b", 2048, "Acme Rail Services" + ) + assert out.industry == "freight rail maintenance" + assert out.location == "Omaha, Nebraska" + assert agent_instance.run.call_args[0][0].company == "Acme Rail Services" + + +def test_research_target_uses_research_prompt_and_num_ctx(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + "x", "y" + ) + with p_instr, p_openai, p_agent: + llm.research_target("http://localhost:11434", "qwen2.5:32b", 4096, "Acme") + config = agent_cls.__getitem__.return_value.call_args.kwargs["config"] + assert config.system_prompt_generator is llm._RESEARCH_PROMPT + assert config.model_api_parameters["extra_body"]["options"]["num_ctx"] == 4096 + + +def test_research_prompt_tells_model_to_return_empty_when_unsure(): + rendered = llm._RESEARCH_PROMPT.generate_prompt().lower() + assert "empty string" in rendered + assert "no internet access" in rendered + + +def test_research_target_strips_and_blanks_whitespace_only(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + " healthcare ", " " + ) + with p_instr, p_openai, p_agent: + out = llm.research_target("http://localhost:11434", "m", 2048, "Acme") + assert out.industry == "healthcare" + assert out.location == "" + + +def test_research_target_caps_overlong_values(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + "A" * 500, "B" * (llm.MAX_RESEARCH_FIELD_LEN + 1) + ) + with p_instr, p_openai, p_agent: + out = llm.research_target("http://localhost:11434", "m", 2048, "Acme") + assert len(out.industry) == llm.MAX_RESEARCH_FIELD_LEN + assert len(out.location) == llm.MAX_RESEARCH_FIELD_LEN + + +def test_research_target_tolerates_non_string_fields(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + None, 42 + ) + with p_instr, p_openai, p_agent: + out = llm.research_target("http://localhost:11434", "m", 2048, "Acme") + assert out.industry == "" + assert out.location == "" + + +def test_research_target_timeout_forwarded_and_translated(): + p_instr, p_openai, p_agent, agent_cls, agent_instance = _patch_research_agent( + "x", "y" + ) + agent_instance.run.side_effect = openai.APITimeoutError( + request=httpx.Request("POST", "http://localhost:11434/v1/chat/completions") + ) + with p_instr, p_openai as openai_cls, p_agent: + with pytest.raises(llm.LLMTimeoutError): + llm.research_target( + "http://localhost:11434", "m", 2048, "Acme", timeout=7.5 + ) + assert openai_cls.call_args.kwargs["timeout"] == 7.5 + + +def test_clean_research_field_collapses_internal_whitespace(): + assert llm.clean_research_field("commercial \n construction") == ( + "commercial construction" + ) diff --git a/tests/test_ollama_target_research.py b/tests/test_ollama_target_research.py new file mode 100644 index 0000000..222240a --- /dev/null +++ b/tests/test_ollama_target_research.py @@ -0,0 +1,301 @@ +"""Tests for LLM target-research pre-fill (menu 12, Target info mode). + +Covers both layers: hcatOllamaResearchTarget in main (spinner + failure +degradation) and ollama_attack's editable-default prompts in attacks. The LLM is +always mocked; no network. +""" + +import os +from unittest import mock +from unittest.mock import MagicMock, patch + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" +from hate_crack import llm # noqa: E402 +from hate_crack import main as hc_main # noqa: E402 +from hate_crack.attacks import ollama_attack # noqa: E402 + + +def _make_ctx(hash_type: str = "1000", hash_file: str = "/tmp/hashes.txt") -> MagicMock: + ctx = MagicMock() + ctx.hcatHashType = hash_type + ctx.hcatHashFile = hash_file + return ctx + + +class _InputRecorder: + """input() stub that records prompts and replays canned answers.""" + + def __init__(self, answers): + self.answers = list(answers) + self.prompts: list[str] = [] + + def __call__(self, prompt=""): + self.prompts.append(prompt) + return self.answers.pop(0) + + +def _run_target_mode(ctx, answers): + recorder = _InputRecorder(answers) + with ( + patch("hate_crack.attacks.interactive_menu", return_value="1"), + patch("builtins.input", recorder), + ): + ollama_attack(ctx) + return recorder + + +# --------------------------------------------------------------------------- +# attacks layer: prompt defaults +# --------------------------------------------------------------------------- + + +class TestResearchPrefill: + def test_successful_research_prefills_defaults(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "freight rail maintenance", + "location": "Omaha, Nebraska", + } + + recorder = _run_target_mode(ctx, ["Acme Rail", "", ""]) + + ctx.hcatOllamaResearchTarget.assert_called_once_with("Acme Rail") + assert recorder.prompts == [ + "Company name: ", + "Industry (freight rail maintenance): ", + "Location (Omaha, Nebraska): ", + ] + + def test_enter_accepts_the_suggested_defaults(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "healthcare", + "location": "Austin, Texas", + } + + _run_target_mode(ctx, ["Acme", "", ""]) + + assert ctx.hcatOllama.call_args[0][3] == { + "company": "Acme", + "industry": "healthcare", + "location": "Austin, Texas", + } + + def test_typed_input_overrides_the_default(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "healthcare", + "location": "Austin, Texas", + } + + _run_target_mode(ctx, ["Acme", " banking ", "Berlin"]) + + assert ctx.hcatOllama.call_args[0][3] == { + "company": "Acme", + "industry": "banking", + "location": "Berlin", + } + + def test_partial_research_prefills_only_known_field(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "mining", + "location": "", + } + + recorder = _run_target_mode(ctx, ["Acme", "", "Perth"]) + + assert recorder.prompts[1] == "Industry (mining): " + assert recorder.prompts[2] == "Location: " + assert ctx.hcatOllama.call_args[0][3]["location"] == "Perth" + + def test_empty_research_falls_back_to_blank_prompts(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = {"industry": "", "location": ""} + + recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"]) + + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + assert ctx.hcatOllama.call_args[0][3] == { + "company": "Acme", + "industry": "tech", + "location": "NYC", + } + + def test_whitespace_only_research_is_treated_as_no_suggestion(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": " ", + "location": "\t\n", + } + + recorder = _run_target_mode(ctx, ["Acme", "", ""]) + + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + + def test_overlong_suggestion_is_capped_in_the_prompt(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "z" * 500, + "location": "", + } + + recorder = _run_target_mode(ctx, ["Acme", "", ""]) + + assert recorder.prompts[1] == f"Industry ({'z' * llm.MAX_RESEARCH_FIELD_LEN}): " + industry = ctx.hcatOllama.call_args[0][3]["industry"] + assert len(industry) == llm.MAX_RESEARCH_FIELD_LEN + + def test_research_exception_falls_back_and_does_not_abort(self) -> None: + """Even an unexpected raise from the research call must not block.""" + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.side_effect = RuntimeError("boom") + + recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"]) + + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + ctx.hcatOllama.assert_called_once() + + def test_non_dict_research_result_falls_back(self) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = None + + recorder = _run_target_mode(ctx, ["Acme", "tech", "NYC"]) + + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + + def test_blank_company_skips_research_entirely(self) -> None: + ctx = _make_ctx() + + recorder = _run_target_mode(ctx, ["", "tech", "NYC"]) + + ctx.hcatOllamaResearchTarget.assert_not_called() + assert recorder.prompts == ["Company name: ", "Industry: ", "Location: "] + + def test_suggestions_are_labelled_as_guesses(self, capsys) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "healthcare", + "location": "Austin, Texas", + } + + _run_target_mode(ctx, ["Acme", "", ""]) + + out = capsys.readouterr().out + assert "GUESSES" in out + assert "not verified OSINT" in out + + def test_no_guess_banner_when_research_returns_nothing(self, capsys) -> None: + ctx = _make_ctx() + ctx.hcatOllamaResearchTarget.return_value = {"industry": "", "location": ""} + + _run_target_mode(ctx, ["Acme", "", ""]) + + assert "GUESSES" not in capsys.readouterr().out + + def test_hcatOllama_call_shape_unchanged(self) -> None: + ctx = _make_ctx(hash_type="1800", hash_file="/tmp/sha512.txt") + ctx.hcatOllamaResearchTarget.return_value = { + "industry": "healthcare", + "location": "Austin", + } + + _run_target_mode(ctx, ["Acme", "", ""]) + + args = ctx.hcatOllama.call_args[0] + assert args[0] == "1800" + assert args[1] == "/tmp/sha512.txt" + assert args[2] == "target" + assert set(args[3]) == {"company", "industry", "location"} + + +# --------------------------------------------------------------------------- +# main layer: hcatOllamaResearchTarget +# --------------------------------------------------------------------------- + + +class TestHcatOllamaResearchTarget: + def test_returns_researched_fields_and_shows_progress(self) -> None: + result = hc_main.llm.TargetResearchOutput(industry="mining", location="Perth") + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object(hc_main, "ollamaModel", "qwen2.5:32b"), + mock.patch.object(hc_main, "ollamaTimeout", 30.0), + mock.patch.object(hc_main.llm, "research_target", return_value=result), + mock.patch.object(hc_main, "spinner") as spin, + ): + out = hc_main.hcatOllamaResearchTarget("Acme") + + assert out == {"industry": "mining", "location": "Perth"} + # The call must be wrapped in the progress spinner, not look like a hang. + assert spin.call_count == 1 + assert "Acme" in spin.call_args[0][0] + + def test_forwards_config_values_to_research_target(self) -> None: + result = hc_main.llm.TargetResearchOutput(industry="", location="") + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object(hc_main, "ollamaUrl", "http://localhost:11434"), + mock.patch.object(hc_main, "ollamaModel", "m"), + mock.patch.object(hc_main, "ollamaNumCtx", 4096), + mock.patch.object(hc_main, "ollamaTimeout", 12.5), + mock.patch.object( + hc_main.llm, "research_target", return_value=result + ) as research, + ): + hc_main.hcatOllamaResearchTarget("Acme") + + assert research.call_args[0] == ("http://localhost:11434", "m", 4096, "Acme") + assert research.call_args.kwargs["timeout"] == 12.5 + + def test_timeout_degrades_to_blank_suggestions(self, capsys) -> None: + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object(hc_main, "ollamaTimeout", 5.0), + mock.patch.object( + hc_main.llm, + "research_target", + side_effect=hc_main.llm.LLMTimeoutError("no response"), + ), + ): + out = hc_main.hcatOllamaResearchTarget("Acme") + + assert out == {"industry": "", "location": ""} + assert "timed out" in capsys.readouterr().out + + def test_connection_failure_degrades_to_blank_suggestions(self, capsys) -> None: + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object( + hc_main.llm, + "research_target", + side_effect=ConnectionError("connection refused"), + ), + ): + out = hc_main.hcatOllamaResearchTarget("Acme") + + assert out == {"industry": "", "location": ""} + assert "unavailable" in capsys.readouterr().out + + def test_disabled_by_config_skips_the_model_call(self) -> None: + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", False), + mock.patch.object(hc_main.llm, "research_target") as research, + ): + out = hc_main.hcatOllamaResearchTarget("Acme") + + research.assert_not_called() + assert out == {"industry": "", "location": ""} + + def test_blank_company_skips_the_model_call(self) -> None: + with ( + mock.patch.object(hc_main, "ollamaAutoResearch", True), + mock.patch.object(hc_main.llm, "research_target") as research, + ): + out = hc_main.hcatOllamaResearchTarget("") + + research.assert_not_called() + assert out == {"industry": "", "location": ""} + + def test_auto_research_default_is_enabled(self) -> None: + assert hc_main.ollamaAutoResearch is True From a75134274489bdebd2d926a688129a3e8605c833 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:51:48 -0400 Subject: [PATCH 30/51] style(ui): normalize interactive prompt decoration and default-value format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove [*] prefix from all input() prompts (10 sites); [*] is a status/output marker and must not appear in user-facing input prompts. print() calls that use [*] are untouched. - Normalize default-value hints to the bare-parentheses form (7 instances already used it vs. 1 using "default N"); the lone outlier "\nEnter n-gram group size (default 3): " is rewritten to "(3)". - Add \n prefix to combipow "Add spaces between words?" prompt so it does not run flush against the last output line of the wordlist path loop. - All other prompts already satisfy: capital first letter, ends with ": ", and correct leading-whitespace context (tab-indented where the surrounding menu uses tabs, plain \n or none where it follows un-indented output). - No input() calls added or removed (verified: attacks.py 42→42, main.py 26→26). Co-Authored-By: Claude --- hate_crack/attacks.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index c84ccf2..f1e704c 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -795,7 +795,7 @@ def combipow_crack(ctx: Any) -> None: _notify.prompt_notify_for_attack("Combipow") wordlist = None while wordlist is None: - path = input("\n[*] Enter path to wordlist (max 63 lines recommended): ").strip() + path = input("\nEnter path to wordlist (max 63 lines recommended): ").strip() if not path: continue if not os.path.isfile(path): @@ -813,7 +813,7 @@ def combipow_crack(ctx: Any) -> None: f"[*] Warning: {line_count} lines will generate a large number of combinations." ) wordlist = path - use_space_sep = input("[*] Add spaces between words? (Y/n): ").strip().lower() != "n" + use_space_sep = input("\nAdd spaces between words? (Y/n): ").strip().lower() != "n" ctx.hcatCombipow(ctx.hcatHashType, ctx.hcatHashFile, wordlist, use_space_sep) @@ -913,7 +913,7 @@ def ngram_attack(ctx: Any) -> None: print("No corpus selected. Aborting ngram attack.") return - group_size_raw = input("\nEnter n-gram group size (default 3): ").strip() + group_size_raw = input("\nEnter n-gram group size (3): ").strip() try: group_size = int(group_size_raw) if group_size_raw else 3 except ValueError: @@ -1118,8 +1118,8 @@ def wordlist_filter_length(ctx: Any) -> None: if not outfile: print("[!] Output path cannot be empty.") return - min_len = int(input("[*] Minimum length: ").strip() or "0") - max_len = int(input("[*] Maximum length: ").strip() or "0") + min_len = int(input("Minimum length: ").strip() or "0") + max_len = int(input("Maximum length: ").strip() or "0") if ctx.wordlist_filter_len(infile, outfile, min_len, max_len): print(f"\n[*] Filtered wordlist written to: {outfile}") else: @@ -1139,7 +1139,7 @@ def wordlist_filter_charclass_include(ctx: Any) -> None: print("[!] Output path cannot be empty.") return print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)") - mask = int(input("[*] Enter mask value: ").strip() or "0") + mask = int(input("Mask value: ").strip() or "0") if ctx.wordlist_filter_req_include(infile, outfile, mask): print(f"\n[*] Filtered wordlist written to: {outfile}") else: @@ -1159,7 +1159,7 @@ def wordlist_filter_charclass_exclude(ctx: Any) -> None: print("[!] Output path cannot be empty.") return print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive)") - mask = int(input("[*] Enter mask value: ").strip() or "0") + mask = int(input("Mask value: ").strip() or "0") if ctx.wordlist_filter_req_exclude(infile, outfile, mask): print(f"\n[*] Filtered wordlist written to: {outfile}") else: @@ -1178,8 +1178,8 @@ def wordlist_cut_substring(ctx: Any) -> None: if not outfile: print("[!] Output path cannot be empty.") return - offset = int(input("[*] Byte offset to start from: ").strip() or "0") - raw_length = input("[*] Length (leave blank for rest of line): ").strip() + offset = int(input("Byte offset to start from: ").strip() or "0") + raw_length = input("Length (leave blank for rest of line): ").strip() length = int(raw_length) if raw_length else None if ctx.wordlist_cutb(infile, outfile, offset, length): print(f"\n[*] Output written to: {outfile}") @@ -1211,7 +1211,7 @@ def wordlist_subtract_words(ctx: Any) -> None: print("\n[*] Subtract mode:") print(" 1. Single remove file (rli2 - faster for one file)") print(" 2. Multiple remove files (rli)") - mode = input("[*] Choose mode (1/2): ").strip() + mode = input("Choose mode (1/2): ").strip() if mode == "1": infile = ctx.select_file_with_autocomplete( @@ -1274,7 +1274,7 @@ def wordlist_shard(ctx: Any) -> None: if not outbase: print("[!] Output path cannot be empty.") return - mod = int(input("[*] Shard count (e.g. 4 to split into 4 parts): ").strip() or "0") + mod = int(input("Shard count (e.g. 4 to split into 4 parts): ").strip() or "0") if mod < 2: print("[!] Shard count must be at least 2.") return From 119dc29da462da563f92958e13f7113edb72e406 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 18:53:39 -0400 Subject: [PATCH 31/51] docs: record UI polish and LLM mode additions under 2.12.0 2.12.0 is not yet tagged (latest tag is v2.11.4), so this work folds into that entry rather than cutting a new version. Co-Authored-By: Claude --- CHANGELOG.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33c3e84..2c57ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,37 @@ Dates are omitted for releases predating this file; see the git tags for exact t - **Wordlist (denylist) generation mode** for the LLM attack is now reachable from the menu: select the LLM attack (option 12), then choose "Wordlist" to derive basewords from a sample wordlist. +- **Cracked-password generation mode** for the LLM attack. Once a session has recovered + plaintexts, option 3 feeds them back to the model, which infers the organization's own + password conventions and generates new candidates in that style. Offered only when + `.out` has content, and it uses a dedicated prompt that tells the model not to + re-emit passwords already cracked. +- **Target research pre-fills the industry and location prompts.** In target mode, entering + the company name asks the local model to recall that organization's industry and location, + then offers them as editable defaults (Enter accepts, typing overrides). Values are + labelled as model guesses rather than verified OSINT, whitespace-collapsed, and capped at + 80 characters. Research runs entirely against the configured local Ollama server, so the + client name is never sent to a third party. Any failure or timeout falls back to blank + prompts and never blocks the attack. Disable with `ollamaAutoResearch: false`. +- **Live progress spinner** with an elapsed-seconds counter during Ollama generation, so a + model loading into VRAM is distinguishable from a hang. Automatically suppressed when + stdout is not a TTY. +- **`ollamaMaxSampleLines`** (default 500) caps how many sample passwords are sent to the + model, for both wordlist and cracked-password modes. ### Fixed +- **A large sample wordlist no longer stalls the LLM attack.** Wordlist mode read every line + into memory and pasted all of them into the prompt, so pointing it at `rockyou.txt` + materialized hundreds of megabytes and overran the model's context window — which looked + like a hang. The file is now streamed and evenly sampled across its whole length, and the + count actually used is reported (`Sampled 500 of 14,344,391 passwords from wordlist.`). +- **`HATE_CRACK_ARROW_MENU=1` now works in the LLM and OMEN submenus.** They hand-rolled + `print()` + `input()` instead of the shared menu helper, so arrow-key navigation silently + did nothing there. +- **A typo in a wordlist or generation-mode prompt no longer aborts the whole attack.** The + pickers and submenus re-prompt instead of dropping back to the main menu, and offer an + explicit cancel. - **The LLM attack no longer hangs forever waiting on Ollama.** Generation requests are now bounded by a configurable timeout (`ollamaTimeout` in `config.json`, default 300 seconds). Previously, if Ollama accepted the connection but never replied — most commonly a large From 878a350c16060fbd80177667d79bba4645427cf5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:03:55 +0000 Subject: [PATCH 32/51] chore(deps): update beautifulsoup4 requirement from >=4.12.0 to >=4.15.0 Updates the requirements on [beautifulsoup4](https://www.crummy.com/software/BeautifulSoup/bs4/) to permit the latest version. --- updated-dependencies: - dependency-name: beautifulsoup4 dependency-version: 4.15.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 2d0a432..cc07bcf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "requests>=2.34.2", - "beautifulsoup4>=4.12.0", + "beautifulsoup4>=4.15.0", "openpyxl>=3.0.0", "packaging>=26.2", "simple-term-menu==1.6.6", From e9727631beaa40ce4a010095b93879ab063a5afd Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 19:10:47 -0400 Subject: [PATCH 33/51] docs: split changelog to match the v2.12.0 and v2.13.0 tags The UI polish, cracked-password mode, and target research were all written into the [2.12.0] section on the assumption they would ship in that release. They did not: auto-tag cuts a release per merge that contains feat/fix commits, so #129 became v2.12.0 and #132 became v2.13.0. Move those entries into a new [2.13.0] section so each heading lists what its tag actually contains. No entry is duplicated or dropped. Verified against 'git log v2.11.4..v2.12.0' and 'git log v2.12.0..v2.13.0'. Co-Authored-By: Claude --- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c57ba1..185e62d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,21 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. -## [2.12.0] - 2026-07-24 - -### Changed - -- **LLM attack now uses the Atomic Agents framework** for structured (JSON) candidate - generation instead of raw HTTP + regex line-parsing. Candidate generation lives in the - new `hate_crack/llm.py` module. -- **Default Ollama model is now `qwen2.5:32b`** (was `mistral`), chosen for reliable - structured-output adherence. +## [2.13.0] - 2026-07-24 ### Added -- **Wordlist (denylist) generation mode** for the LLM attack is now reachable from the - menu: select the LLM attack (option 12), then choose "Wordlist" to derive basewords from - a sample wordlist. - **Cracked-password generation mode** for the LLM attack. Once a session has recovered plaintexts, option 3 feeds them back to the model, which infers the organization's own password conventions and generates new candidates in that style. Offered only when @@ -31,9 +20,9 @@ Dates are omitted for releases predating this file; see the git tags for exact t the company name asks the local model to recall that organization's industry and location, then offers them as editable defaults (Enter accepts, typing overrides). Values are labelled as model guesses rather than verified OSINT, whitespace-collapsed, and capped at - 80 characters. Research runs entirely against the configured local Ollama server, so the - client name is never sent to a third party. Any failure or timeout falls back to blank - prompts and never blocks the attack. Disable with `ollamaAutoResearch: false`. + 80 characters. Research runs entirely against the local Ollama server, so the client name + is never sent to a third party. Any failure or timeout falls back to blank prompts and + never blocks the attack. Disable with `ollamaAutoResearch: false`. - **Live progress spinner** with an elapsed-seconds counter during Ollama generation, so a model loading into VRAM is distinguishable from a hang. Automatically suppressed when stdout is not a TTY. @@ -53,6 +42,37 @@ Dates are omitted for releases predating this file; see the git tags for exact t - **A typo in a wordlist or generation-mode prompt no longer aborts the whole attack.** The pickers and submenus re-prompt instead of dropping back to the main menu, and offer an explicit cancel. + +### Changed + +- **Interactive prompt formatting normalized** — the `[*] ` marker is no longer used on + input prompts (it denotes status output elsewhere), and default-value hints use a single + form. + +### Build + +- **Local `uv` Python pinned to 3.13** via `.python-version`. `requires-python = ">=3.13"` + meant a fresh worktree picked CPython 3.15.0a7 and failed to build pyo3 0.26 (via + `jiter`/`fastuuid`/`pydantic-core`). CI already pinned 3.13. + +## [2.12.0] - 2026-07-24 + +### Changed + +- **LLM attack now uses the Atomic Agents framework** for structured (JSON) candidate + generation instead of raw HTTP + regex line-parsing. Candidate generation lives in the + new `hate_crack/llm.py` module. +- **Default Ollama model is now `qwen2.5:32b`** (was `mistral`), chosen for reliable + structured-output adherence. + +### Added + +- **Wordlist (denylist) generation mode** for the LLM attack is now reachable from the + menu: select the LLM attack (option 12), then choose "Wordlist" to derive basewords from + a sample wordlist. + +### Fixed + - **The LLM attack no longer hangs forever waiting on Ollama.** Generation requests are now bounded by a configurable timeout (`ollamaTimeout` in `config.json`, default 300 seconds). Previously, if Ollama accepted the connection but never replied — most commonly a large From 2376ac5c10a673466bfe8ed1022030ad5a814ed2 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 19:19:38 -0400 Subject: [PATCH 34/51] fix(llm): only prepend http:// to OLLAMA_HOST when no scheme is present ollamaUrl was built as "http://" + OLLAMA_HOST, so any value carrying a scheme - the form Ollama's own tooling accepts - was mangled: OLLAMA_HOST=https://ollama.example.com -> http://https://ollama.example.com The LLM attack then could not connect, and reaching a remote Ollama over TLS was impossible. llm.py derives its client base URL as f"{ollamaUrl}/v1", so the malformation propagated into the OpenAI-compatible client. Extract _normalize_ollama_url() so the logic is unit-testable - ollamaUrl is assigned at module import, which is otherwise only reachable via reload - and prepend the scheme only when absent. Also strip trailing slashes, since callers append paths. The bare host:port default is unchanged. Fixes #119 Co-Authored-By: Claude --- CHANGELOG.md | 12 +++++ hate_crack/main.py | 19 ++++++- tests/test_ollama_url_normalization.py | 75 ++++++++++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/test_ollama_url_normalization.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 185e62d..539f047 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.13.1] - 2026-07-24 + +### Fixed + +- **`OLLAMA_HOST` values that include a scheme no longer produce a malformed URL** + (issue #119). The Ollama base URL was built as `"http://" + OLLAMA_HOST`, so a value in + the form Ollama's own tooling accepts — `http://box:11434` or + `https://ollama.example.com` — became `http://http://box:11434` and the LLM attack could + not connect. `http://` is now prepended only when no scheme is present, and trailing + slashes are stripped because callers append paths (`f"{ollamaUrl}/v1"`). Reaching a remote + Ollama over TLS works as a result. The bare `host:port` default is unchanged. + ## [2.13.0] - 2026-07-24 ### Added diff --git a/hate_crack/main.py b/hate_crack/main.py index f6dafd0..e13b237 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -361,6 +361,23 @@ else: hcatPotfilePath = os.path.join(hate_path, hcatPotfilePath) +def _normalize_ollama_url(host: str) -> str: + """Turn an ``OLLAMA_HOST`` value into a usable base URL. + + Ollama's own tooling accepts both a bare ``host:port`` and a full URL, so + accept either. Only prepend ``http://`` when no scheme is present; + unconditionally prepending it produced URLs like + ``http://https://ollama.example.com``. Trailing slashes are stripped + because callers append paths (``f"{ollamaUrl}/v1"``). + """ + host = (host or "").strip() + if not host: + return "http://localhost:11434" + if "://" not in host: + host = "http://" + host + return host.rstrip("/") + + def _maybe_append_username_flag(cmd): """Append --username if the active hash file has user:hash format and the flag isn't already present (from hcatTuning or elsewhere).""" @@ -450,7 +467,7 @@ hcatGoodMeasureBaseList = config_parser["hcatGoodMeasureBaseList"] hcatDebugLogPath = os.path.expanduser(config_parser["hcatDebugLogPath"]) -ollamaUrl = "http://" + os.environ.get("OLLAMA_HOST", "localhost:11434") +ollamaUrl = _normalize_ollama_url(os.environ.get("OLLAMA_HOST", "localhost:11434")) ollamaModel = config_parser.get("ollamaModel", "qwen2.5:32b") ollamaNumCtx = int(config_parser.get("ollamaNumCtx", 2048)) ollamaTimeout = float(config_parser.get("ollamaTimeout", 300)) diff --git a/tests/test_ollama_url_normalization.py b/tests/test_ollama_url_normalization.py new file mode 100644 index 0000000..845f116 --- /dev/null +++ b/tests/test_ollama_url_normalization.py @@ -0,0 +1,75 @@ +"""Tests for OLLAMA_HOST -> ollamaUrl normalization (issue #119). + +``ollamaUrl`` used to be built as ``"http://" + OLLAMA_HOST``, which mangled any +value that already carried a scheme -- the form Ollama's own tooling accepts. +""" + +import importlib +import os +from unittest import mock + +import pytest + +from hate_crack import main as hc_main + + +@pytest.mark.parametrize( + ("host", "expected"), + [ + # Bare host:port -- the historical default, must keep working. + ("localhost:11434", "http://localhost:11434"), + ("box:11434", "http://box:11434"), + ("127.0.0.1:11434", "http://127.0.0.1:11434"), + # Scheme already present -- previously produced http://http://... + ("http://box:11434", "http://box:11434"), + ("https://ollama.example.com", "https://ollama.example.com"), + ("https://ollama.example.com:443", "https://ollama.example.com:443"), + # Trailing slashes stripped: callers append f"{ollamaUrl}/v1". + ("http://box:11434/", "http://box:11434"), + ("https://ollama.example.com///", "https://ollama.example.com"), + ("box:11434/", "http://box:11434"), + # Whitespace and empty values fall back to the default. + (" box:11434 ", "http://box:11434"), + ("", "http://localhost:11434"), + (" ", "http://localhost:11434"), + ], +) +def test_normalize_ollama_url(host, expected): + assert hc_main._normalize_ollama_url(host) == expected + + +def test_scheme_is_never_doubled(): + """The exact regression from issue #119.""" + for host in ("http://box:11434", "https://ollama.example.com"): + assert "http://http" not in hc_main._normalize_ollama_url(host) + assert "http://https" not in hc_main._normalize_ollama_url(host) + + +def test_result_builds_a_valid_v1_endpoint(): + """llm.py derives its client base URL as f"{ollamaUrl}/v1".""" + for host in ("box:11434", "http://box:11434/", "https://x.example.com//"): + url = hc_main._normalize_ollama_url(host) + assert f"{url}/v1".count("//") == 1 + + +def test_module_level_ollamaUrl_honours_a_scheme_in_the_env(): + """Guard the wiring, not just the helper: ollamaUrl is built at import.""" + with mock.patch.dict( + os.environ, {"OLLAMA_HOST": "https://ollama.example.com"}, clear=False + ): + reloaded = importlib.reload(hc_main) + try: + assert reloaded.ollamaUrl == "https://ollama.example.com" + finally: + # Restore the module to the ambient environment for other tests. + importlib.reload(reloaded) + + +def test_module_level_ollamaUrl_defaults_to_localhost(): + env = {k: v for k, v in os.environ.items() if k != "OLLAMA_HOST"} + with mock.patch.dict(os.environ, env, clear=True): + reloaded = importlib.reload(hc_main) + try: + assert reloaded.ollamaUrl == "http://localhost:11434" + finally: + importlib.reload(reloaded) From d2e45afb4a87ffbd7946710775f481ff0635538a Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 19:55:43 -0400 Subject: [PATCH 35/51] feat: add rule-chain builder for non-interactive mode Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/noninteractive.py | 38 +++++++++++++++++++++++++++++ tests/test_noninteractive.py | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 hate_crack/noninteractive.py create mode 100644 tests/test_noninteractive.py diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py new file mode 100644 index 0000000..154e615 --- /dev/null +++ b/hate_crack/noninteractive.py @@ -0,0 +1,38 @@ +"""Non-interactive (scripted) attack entry points for hate_crack. + +These helpers translate parsed argparse namespaces into calls against the +existing ``hcat*`` attack functions on the main module (passed in as ``ctx``, +the same pattern ``attacks.py`` uses). See +``docs/superpowers/specs/2026-07-24-cli-noninteractive-design.md``. +""" + +import os +from typing import Any + + +def build_rule_chains(ctx: Any, rule_tokens): + """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. + + Each token becomes one attack pass. A token may chain multiple rule files + with ``+`` (mirroring the interactive rule selector). Filenames resolve + against ``ctx.rulesDirectory``. Returns ``[""]`` when no rules are given + (equivalent to the interactive "run without rules" choice). + + Raises ``FileNotFoundError`` (with the offending filename as its argument) + if any named rule file is missing. + """ + if not rule_tokens: + return [""] + chains = [] + for token in rule_tokens: + chain = "" + for name in token.split("+"): + name = name.strip() + if not name: + continue + path = os.path.join(ctx.rulesDirectory, name) + if not os.path.isfile(path): + raise FileNotFoundError(name) + chain = f"{chain} -r {path}".strip() + chains.append(chain) + return chains diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py new file mode 100644 index 0000000..194e801 --- /dev/null +++ b/tests/test_noninteractive.py @@ -0,0 +1,47 @@ +import os +from types import SimpleNamespace + +import pytest + +from hate_crack import noninteractive as ni + + +def _ctx_with_rules(tmp_path, *rule_names): + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + for name in rule_names: + (rules_dir / name).write_text(":\n") + return SimpleNamespace(rulesDirectory=str(rules_dir)) + + +def test_build_rule_chains_no_rules_returns_empty_chain(tmp_path): + ctx = _ctx_with_rules(tmp_path) + assert ni.build_rule_chains(ctx, []) == [""] + assert ni.build_rule_chains(ctx, None) == [""] + + +def test_build_rule_chains_single_rule(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule"]) + expected = os.path.join(ctx.rulesDirectory, "best64.rule") + assert chains == [f"-r {expected}"] + + +def test_build_rule_chains_chained_token(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule+d3ad0ne.rule"]) + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a} -r {b}"] + + +def test_build_rule_chains_multiple_tokens_are_separate_passes(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule", "d3ad0ne.rule"]) + assert len(chains) == 2 + + +def test_build_rule_chains_missing_file_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path) + with pytest.raises(FileNotFoundError): + ni.build_rule_chains(ctx, ["nope.rule"]) From db374edda29d8aa860831791ae9130a6418d2f53 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 19:59:22 -0400 Subject: [PATCH 36/51] refactor: harden rule-chain builder (types, empty-token guard, stronger tests) Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/noninteractive.py | 4 +++- tests/test_noninteractive.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index 154e615..9790fdb 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -10,7 +10,7 @@ import os from typing import Any -def build_rule_chains(ctx: Any, rule_tokens): +def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. Each token becomes one attack pass. A token may chain multiple rule files @@ -34,5 +34,7 @@ def build_rule_chains(ctx: Any, rule_tokens): if not os.path.isfile(path): raise FileNotFoundError(name) chain = f"{chain} -r {path}".strip() + if not chain: + raise ValueError(f"Rule token {token!r} resolved to no rule files") chains.append(chain) return chains diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 194e801..3fd50e8 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -38,10 +38,18 @@ def test_build_rule_chains_chained_token(tmp_path): def test_build_rule_chains_multiple_tokens_are_separate_passes(tmp_path): ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") chains = ni.build_rule_chains(ctx, ["best64.rule", "d3ad0ne.rule"]) - assert len(chains) == 2 + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a}", f"-r {b}"] def test_build_rule_chains_missing_file_raises(tmp_path): ctx = _ctx_with_rules(tmp_path) - with pytest.raises(FileNotFoundError): + with pytest.raises(FileNotFoundError, match="nope.rule"): ni.build_rule_chains(ctx, ["nope.rule"]) + + +def test_build_rule_chains_empty_token_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + with pytest.raises(ValueError): + ni.build_rule_chains(ctx, ["+"]) From 27791457fbb43bbc65f76d6e7ae3b317ee5488dc Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:01:06 -0400 Subject: [PATCH 37/51] feat: add non-interactive attack dispatcher Appends `run_noninteractive(ctx, args)` to noninteractive.py, which dispatches quick/dict/brute/topmask commands to the appropriate hcat* function. Returns 0 on success, 1 on bad inputs, 2 on unknown command. Includes 6 new unit tests (12 total in file), all passing. Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/noninteractive.py | 48 +++++++++++++++++++++ tests/test_noninteractive.py | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index 9790fdb..f473f5c 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -38,3 +38,51 @@ def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: raise ValueError(f"Rule token {token!r} resolved to no rule files") chains.append(chain) return chains + + +def run_noninteractive(ctx: Any, args: Any) -> int: + """Run a non-interactive attack. Returns a process exit code. + + ``ctx`` is the main module (live ``hcat*`` functions, ``rulesDirectory``, + ``resolve_path``, ``hcatHashType``/``hcatHashFile`` already set by the + preprocessing block in ``main()``). ``args`` is the parsed subparser + namespace whose ``command`` selects the attack. + """ + command = args.command + + if command == "quick": + wordlist = ctx.resolve_path(args.wordlist) + if not wordlist or not os.path.isfile(wordlist): + print(f"Error: wordlist not found: {args.wordlist}") + return 1 + try: + chains = build_rule_chains(ctx, args.rules) + except (FileNotFoundError, ValueError) as exc: + print(f"Error: invalid --rules value: {exc}") + return 1 + for chain in chains: + ctx.hcatQuickDictionary( + ctx.hcatHashType, + ctx.hcatHashFile, + chain, + wordlist, + attack_name="Quick Crack", + ) + return 0 + + if command == "dict": + ctx.hcatDictionary(ctx.hcatHashType, ctx.hcatHashFile) + return 0 + + if command == "brute": + ctx.hcatBruteForce( + ctx.hcatHashType, ctx.hcatHashFile, args.min_len, args.max_len + ) + return 0 + + if command == "topmask": + ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, args.target_time * 3600) + return 0 + + print(f"Error: unknown non-interactive command: {command}") + return 2 diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 3fd50e8..e06283f 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -53,3 +53,84 @@ def test_build_rule_chains_empty_token_raises(tmp_path): ctx = _ctx_with_rules(tmp_path, "best64.rule") with pytest.raises(ValueError): ni.build_rule_chains(ctx, ["+"]) + + +def _spy_ctx(tmp_path, **overrides): + calls = [] + + def rec(name): + def _fn(*a, **k): + calls.append((name, a, k)) + return _fn + + ctx = SimpleNamespace( + calls=calls, + hcatHashType="1000", + hcatHashFile=str(tmp_path / "hashes.txt"), + rulesDirectory=str(tmp_path / "rules"), + resolve_path=lambda p: os.path.abspath(os.path.expanduser(p)) if p else None, + hcatQuickDictionary=rec("hcatQuickDictionary"), + hcatDictionary=rec("hcatDictionary"), + hcatBruteForce=rec("hcatBruteForce"), + hcatTopMask=rec("hcatTopMask"), + ) + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + +def test_dispatch_quick_calls_quick_dictionary(tmp_path): + (tmp_path / "rules").mkdir() + (tmp_path / "rules" / "best64.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["best64.rule"]) + code = ni.run_noninteractive(ctx, args) + assert code == 0 + assert [c[0] for c in ctx.calls] == ["hcatQuickDictionary"] + name, a, k = ctx.calls[0] + assert a[0] == "1000" + assert a[1] == ctx.hcatHashFile + assert a[2] == f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}" + assert a[3] == os.path.abspath(str(wl)) + + +def test_dispatch_quick_missing_wordlist_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rules=[]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_quick_unknown_rule_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["ghost.rule"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_dict_calls_dictionary(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="dict") + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][0] == "hcatDictionary" + assert ctx.calls[0][1] == ("1000", ctx.hcatHashFile) + + +def test_dispatch_brute_calls_bruteforce_with_lengths(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="brute", min_len=2, max_len=6) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatBruteForce", ("1000", ctx.hcatHashFile, 2, 6), {}) + + +def test_dispatch_topmask_converts_hours_to_seconds(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="topmask", target_time=4) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatTopMask", ("1000", ctx.hcatHashFile, 4 * 3600), {}) From 1f0dcb4589cb0ee6dedd4272f9cba0247b2cf876 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:03:50 -0400 Subject: [PATCH 38/51] test: cover unknown-command and multi-rule dispatch paths Co-Authored-By: Claude Sonnet 4.6 --- tests/test_noninteractive.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index e06283f..72f1436 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -134,3 +134,30 @@ def test_dispatch_topmask_converts_hours_to_seconds(tmp_path): args = SimpleNamespace(command="topmask", target_time=4) assert ni.run_noninteractive(ctx, args) == 0 assert ctx.calls[0] == ("hcatTopMask", ("1000", ctx.hcatHashFile, 4 * 3600), {}) + + +def test_dispatch_unknown_command_returns_2(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="bogus") + assert ni.run_noninteractive(ctx, args) == 2 + assert ctx.calls == [] + + +def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): + rules = tmp_path / "rules" + rules.mkdir() + (rules / "best64.rule").write_text(":\n") + (rules / "d3ad0ne.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace( + command="quick", wordlist=str(wl), rules=["best64.rule", "d3ad0ne.rule"] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert len(ctx.calls) == 2 + chains = [c[1][2] for c in ctx.calls] + assert chains == [ + f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}", + f"-r {os.path.join(ctx.rulesDirectory, 'd3ad0ne.rule')}", + ] From e11a15d4c27ad5352ffedba3cd3d4fb1ff50dd59 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:07:48 -0400 Subject: [PATCH 39/51] feat: wire non-interactive attack subcommands into the CLI (#17) Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/main.py | 40 ++++++++++++++++++------- hate_crack/noninteractive.py | 57 ++++++++++++++++++++++++++++++++++++ tests/test_noninteractive.py | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index f6dafd0..2a14025 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -74,6 +74,7 @@ from hate_crack.cli import ( # noqa: E402 ) from hate_crack import attacks as _attacks # noqa: E402 from hate_crack import llm # noqa: E402 +from hate_crack import noninteractive as _noninteractive # noqa: E402 from hate_crack.progress import spinner # noqa: E402 from hate_crack.menu import interactive_menu # noqa: E402 from hate_crack.username_detect import detect_username_hash_format # noqa: E402 @@ -760,6 +761,7 @@ hcatGenerateRulesCount = 0 hcatPermuteCount = 0 hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False +non_interactive = False hcatUsernamePrefix: bool = False @@ -4058,6 +4060,15 @@ def hashview_api(): print(f"\nError connecting to Hashview: {str(e)}") +def _auto_input(prompt, default=""): + """input() wrapper that returns the default without prompting when running + in non-interactive (scripted) mode. In interactive mode this is identical + to ``input(prompt) or default``.""" + if non_interactive: + return default + return input(prompt) or default + + def _attack_ctx(): ctx = sys.modules.get(__name__) if ctx is None: @@ -4729,6 +4740,7 @@ def main(): global hcatHashFileOrig global lmHashesFound global debug_mode + global non_interactive global hashview_url, hashview_api_key global hcatPath, hcatBin, hcatWordlists, hcatOptimizedWordlists, rulesDirectory global pipalPath, maxruntime, bandrelbasewords @@ -4738,6 +4750,7 @@ def main(): # Initialize global variables hcatHashFile = None + non_interactive = False hcatHashType = None hcatHashFileOrig = None @@ -4824,6 +4837,7 @@ def main(): return parser, hashview_parser subparsers = parser.add_subparsers(dest="command") + _noninteractive.add_attack_subparsers(subparsers) hashview_parser = subparsers.add_parser( "hashview", help="Hashview menu actions" @@ -4950,7 +4964,8 @@ def main(): else: argv = argv_temp # Fallback if subcommand not found - use_subcommand_parser = "hashview" in argv + has_attack_subcommand = bool(argv) and argv[0] in _noninteractive.ATTACK_COMMANDS + use_subcommand_parser = "hashview" in argv or has_attack_subcommand parser, hashview_parser = _build_parser( include_positional=not use_subcommand_parser, include_subcommands=use_subcommand_parser, @@ -4959,6 +4974,8 @@ def main(): global debug_mode debug_mode = args.debug + if getattr(args, "command", None) in _noninteractive.ATTACK_COMMANDS: + non_interactive = True # CLI flags override config file. if getattr(args, "no_potfile_path", False): @@ -5257,8 +5274,8 @@ def main(): f"Detected {computer_count} computer account(s)" " (usernames ending with $)." ) - filter_choice = ( - input("Would you like to ignore computer accounts? (Y) ") or "Y" + filter_choice = _auto_input( + "Would you like to ignore computer accounts? (Y) ", "Y" ) if filter_choice.upper() == "Y": filtered_path = f"{hcatHashFile}.filtered" @@ -5280,12 +5297,10 @@ def main(): ) ) or (lineCount(hcatHashFile + ".lm") > 1): lmHashesFound = True - lmChoice = ( - input( - "LM hashes identified. Would you like to brute force" - " the LM hashes first? (Y) " - ) - or "Y" + lmChoice = _auto_input( + "LM hashes identified. Would you like to brute force" + " the LM hashes first? (Y) ", + "Y", ) if lmChoice.upper() == "Y": hcatLMtoNT() @@ -5331,8 +5346,8 @@ def main(): f"Detected {computer_count} computer account(s)" " (usernames ending with $)." ) - filter_choice = ( - input("Would you like to ignore computer accounts? (Y) ") or "Y" + filter_choice = _auto_input( + "Would you like to ignore computer accounts? (Y) ", "Y" ) if filter_choice.upper() == "Y": filtered_path = f"{hcatHashFile}.filtered" @@ -5411,6 +5426,9 @@ def main(): else: print("No hashes found in POT file.") + if non_interactive: + sys.exit(_noninteractive.run_noninteractive(_attack_ctx(), args)) + # Display Options try: options = get_main_menu_options() diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index f473f5c..4c732e4 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -86,3 +86,60 @@ def run_noninteractive(ctx: Any, args: Any) -> int: print(f"Error: unknown non-interactive command: {command}") return 2 + + +ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") + + +def add_attack_subparsers(subparsers) -> None: + """Register the non-interactive attack subcommands on an argparse + subparsers object (the same one used for ``hashview``). + + Each subcommand carries its own required ``hashfile`` + ``hashtype`` + positionals plus attack-specific flags. + """ + + def _add_target(p): + p.add_argument("hashfile", help="Path to hash file to crack") + p.add_argument("hashtype", help="Hashcat hash type (e.g. 1000 for NTLM)") + + quick = subparsers.add_parser( + "quick", help="Non-interactive quick crack (single wordlist + optional rules)" + ) + _add_target(quick) + quick.add_argument("--wordlist", required=True, help="Path to wordlist file") + quick.add_argument( + "--rules", + nargs="*", + default=[], + metavar="RULE", + help="Rule filename(s) from the rules directory. Chain with '+' " + "(e.g. best64.rule+d3ad0ne.rule). Omit to run without rules.", + ) + + dictp = subparsers.add_parser( + "dict", + help="Non-interactive dictionary methodology (uses configured wordlists)", + ) + _add_target(dictp) + + brute = subparsers.add_parser( + "brute", help="Non-interactive brute force (mask) attack" + ) + _add_target(brute) + brute.add_argument( + "--min", type=int, default=1, dest="min_len", help="Minimum length (default 1)" + ) + brute.add_argument( + "--max", type=int, default=7, dest="max_len", help="Maximum length (default 7)" + ) + + topmask = subparsers.add_parser("topmask", help="Non-interactive top-mask attack") + _add_target(topmask) + topmask.add_argument( + "--target-time", + type=int, + default=4, + dest="target_time", + help="Target completion time in hours (default 4)", + ) diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 72f1436..b25d5bf 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -1,8 +1,10 @@ import os +import sys from types import SimpleNamespace import pytest +import hate_crack.main as hc_main from hate_crack import noninteractive as ni @@ -161,3 +163,55 @@ def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}", f"-r {os.path.join(ctx.rulesDirectory, 'd3ad0ne.rule')}", ] + + +def _run_main(monkeypatch, argv): + monkeypatch.setattr(sys, "argv", ["hate_crack.py"] + argv) + with pytest.raises(SystemExit) as excinfo: + hc_main.main() + return excinfo.value.code + + +def _make_ntlm_hashfile(tmp_path): + # Bare 32-hex NTLM hash: exercises the non-pwdump preprocessing branch. + hf = tmp_path / "hashes.txt" + hf.write_text("aad3b435b51404eeaad3b435b51404ee\n") + return hf + + +def test_main_quick_dispatches_without_prompting(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append((a, k)) + ) + monkeypatch.setattr( + "builtins.input", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompted")), + ) + code = _run_main(monkeypatch, ["quick", str(hf), "1000", "--wordlist", str(wl)]) + assert code == 0 + assert len(calls) == 1 + + +def test_main_brute_dispatches(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + calls = [] + monkeypatch.setattr(hc_main, "hcatBruteForce", lambda *a, **k: calls.append(a)) + code = _run_main( + monkeypatch, ["brute", str(hf), "1000", "--min", "1", "--max", "8"] + ) + assert code == 0 + assert calls[0][2] == 1 and calls[0][3] == 8 + + +def test_main_quick_bad_hashtype_errors(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "w.txt" + wl.write_text("x\n") + code = _run_main( + monkeypatch, ["quick", str(hf), "notanumber", "--wordlist", str(wl)] + ) + assert code != 0 From 1a8bacfbc7725ba1543ff660a5506c9191018c3e Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:15:47 -0400 Subject: [PATCH 40/51] fix: resolve --rules dest collision, non-blocking dedup prompt, subcommand detection (#17) - Change quick subparser --rules to dest=rule_files to prevent collision with the top-level --rules=store_true Hashmob download flag - Update run_noninteractive to read args.rule_files instead of args.rules - Move ATTACK_COMMANDS above run_noninteractive (Fix 5) - Fix has_attack_subcommand to scan all argv elements (supports leading global flags like --debug) - Replace raw input() dedup prompt with _auto_input() so non-interactive runs don't block - Update existing quick dispatch tests to use rule_files= namespace key - Add test_main_quick_with_rules_dispatches: proves --rules routes to crack, not download - Add test_main_debug_flag_before_subcommand: proves leading global flags don't break routing Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/main.py | 14 +++++------ hate_crack/noninteractive.py | 8 +++---- tests/test_noninteractive.py | 46 ++++++++++++++++++++++++++++++++---- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index 2a14025..0dda836 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4964,7 +4964,9 @@ def main(): else: argv = argv_temp # Fallback if subcommand not found - has_attack_subcommand = bool(argv) and argv[0] in _noninteractive.ATTACK_COMMANDS + has_attack_subcommand = any( + arg in _noninteractive.ATTACK_COMMANDS for arg in argv + ) use_subcommand_parser = "hashview" in argv or has_attack_subcommand parser, hashview_parser = _build_parser( include_positional=not use_subcommand_parser, @@ -5370,12 +5372,10 @@ def main(): f"Detected {duplicates} duplicate account(s) out of" f" {total} total NetNTLM hashes." ) - dedup_choice = ( - input( - "Would you like to ignore duplicate accounts" - " (keep first occurrence only)? (Y) " - ) - or "Y" + dedup_choice = _auto_input( + "Would you like to ignore duplicate accounts" + " (keep first occurrence only)? (Y) ", + "Y", ) if dedup_choice.upper() == "Y": hcatHashFileOrig = hcatHashFile diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index 4c732e4..18a23bb 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -9,6 +9,8 @@ the same pattern ``attacks.py`` uses). See import os from typing import Any +ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") + def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. @@ -56,7 +58,7 @@ def run_noninteractive(ctx: Any, args: Any) -> int: print(f"Error: wordlist not found: {args.wordlist}") return 1 try: - chains = build_rule_chains(ctx, args.rules) + chains = build_rule_chains(ctx, args.rule_files) except (FileNotFoundError, ValueError) as exc: print(f"Error: invalid --rules value: {exc}") return 1 @@ -88,9 +90,6 @@ def run_noninteractive(ctx: Any, args: Any) -> int: return 2 -ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") - - def add_attack_subparsers(subparsers) -> None: """Register the non-interactive attack subcommands on an argparse subparsers object (the same one used for ``hashview``). @@ -112,6 +111,7 @@ def add_attack_subparsers(subparsers) -> None: "--rules", nargs="*", default=[], + dest="rule_files", metavar="RULE", help="Rule filename(s) from the rules directory. Chain with '+' " "(e.g. best64.rule+d3ad0ne.rule). Omit to run without rules.", diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index b25d5bf..9386df3 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -87,7 +87,7 @@ def test_dispatch_quick_calls_quick_dictionary(tmp_path): wl = tmp_path / "rockyou.txt" wl.write_text("password\n") ctx = _spy_ctx(tmp_path) - args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["best64.rule"]) + args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["best64.rule"]) code = ni.run_noninteractive(ctx, args) assert code == 0 assert [c[0] for c in ctx.calls] == ["hcatQuickDictionary"] @@ -101,7 +101,7 @@ def test_dispatch_quick_calls_quick_dictionary(tmp_path): def test_dispatch_quick_missing_wordlist_returns_1(tmp_path): (tmp_path / "rules").mkdir() ctx = _spy_ctx(tmp_path) - args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rules=[]) + args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rule_files=[]) assert ni.run_noninteractive(ctx, args) == 1 assert ctx.calls == [] @@ -111,7 +111,7 @@ def test_dispatch_quick_unknown_rule_returns_1(tmp_path): wl = tmp_path / "rockyou.txt" wl.write_text("password\n") ctx = _spy_ctx(tmp_path) - args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["ghost.rule"]) + args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["ghost.rule"]) assert ni.run_noninteractive(ctx, args) == 1 assert ctx.calls == [] @@ -154,7 +154,7 @@ def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): wl.write_text("password\n") ctx = _spy_ctx(tmp_path) args = SimpleNamespace( - command="quick", wordlist=str(wl), rules=["best64.rule", "d3ad0ne.rule"] + command="quick", wordlist=str(wl), rule_files=["best64.rule", "d3ad0ne.rule"] ) assert ni.run_noninteractive(ctx, args) == 0 assert len(ctx.calls) == 2 @@ -215,3 +215,41 @@ def test_main_quick_bad_hashtype_errors(monkeypatch, tmp_path): monkeypatch, ["quick", str(hf), "notanumber", "--wordlist", str(wl)] ) assert code != 0 + + +def test_main_quick_with_rules_dispatches(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best64.rule").write_text(":\n") + monkeypatch.setattr(hc_main, "rulesDirectory", str(rules_dir)) + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a) + ) + code = _run_main( + monkeypatch, + ["quick", str(hf), "1000", "--wordlist", str(wl), "--rules", "best64.rule"], + ) + assert code == 0 + assert len(calls) == 1 + # the rule chain (4th positional arg) must reference best64.rule + assert "best64.rule" in calls[0][2] + + +def test_main_debug_flag_before_subcommand(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "w.txt" + wl.write_text("x\n") + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a) + ) + code = _run_main( + monkeypatch, + ["--debug", "quick", str(hf), "1000", "--wordlist", str(wl)], + ) + assert code == 0 + assert len(calls) == 1 From 289da256094bfc727fcb7dc6422d7cf4b1c82b50 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:19:43 -0400 Subject: [PATCH 41/51] docs: document non-interactive attack subcommands --- CHANGELOG.md | 11 +++++++++++ README.md | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 185e62d..5ac4799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.0] - 2026-07-24 + +### Added + +- **Non-interactive attack subcommands** for scripting (issue #17). Launch a + single attack without the menu: `quick` (wordlist + optional `--rules`), + `dict` (configured-wordlist methodology), `brute` (`--min`/`--max`), and + `topmask` (`--target-time`). Preprocessing prompts auto-accept their + defaults, and the process returns a clean exit code (0 on success, non-zero + on a bad hash file, hash type, wordlist, or rule name). + ## [2.13.0] - 2026-07-24 ### Added diff --git a/README.md b/README.md index 32e2738..792fa5f 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,30 @@ You can also use Python directly: python hate_crack.py ``` +### Non-interactive / scripted usage + +For automation you can launch a single attack directly, bypassing the menu. The attack name is the first argument, followed by the hash file and hashcat hash type. Preprocessing prompts (computer-account filtering, LM-first brute force, duplicate-account dedup) auto-accept their defaults in this mode. The process exits `0` on success and non-zero on error (missing hash file, non-numeric hash type, missing wordlist, or an unknown rule filename). + +```bash +# Quick crack: one wordlist + optional rule(s) from the rules directory +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule + +# Chain two rules in a single run +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule+d3ad0ne.rule + +# Run two rules as two separate passes +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule d3ad0ne.rule + +# Canned dictionary methodology (uses your configured wordlists) +hate_crack dict hashes.txt 1000 + +# Brute force lengths 1-8 +hate_crack brute hashes.txt 1000 --min 1 --max 8 + +# Top-mask attack targeting ~4 hours +hate_crack topmask hashes.txt 1000 --target-time 4 +``` + ------------------------------------------------------------------- ## Troubleshooting From dde6f92d9665f16621e395dae880901e92efaf87 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:35:11 -0400 Subject: [PATCH 42/51] test: stub hashcat potfile-recovery call in non-interactive integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-dispatch 'check POT file' step in main() shells out to the real hashcat binary, which is absent in CI, causing the four test_main_* tests to fail with FileNotFoundError. Stub _run_hashcat_show in the shared _run_main helper — these tests verify dispatch routing and prompt suppression, not potfile recovery. Co-Authored-By: Claude --- tests/test_noninteractive.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 9386df3..07aba49 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -167,6 +167,10 @@ def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): def _run_main(monkeypatch, argv): monkeypatch.setattr(sys, "argv", ["hate_crack.py"] + argv) + # main()'s pre-dispatch potfile-recovery step shells out to the real hashcat + # binary, which is absent in CI. Stub it — these tests exercise dispatch + # routing and prompt suppression, not potfile recovery. + monkeypatch.setattr(hc_main, "_run_hashcat_show", lambda *a, **k: None) with pytest.raises(SystemExit) as excinfo: hc_main.main() return excinfo.value.code From a13b573f79e352f39d3541b2e587b2d82b9855ea Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Jul 2026 13:15:14 -0400 Subject: [PATCH 43/51] style: apply ruff format to hate_crack Normalizes formatting across the package so `ruff format --check` (added to CI in a following commit) passes. No behavioral changes. Co-Authored-By: Claude --- hate_crack/__init__.py | 4 +- hate_crack/api.py | 94 +++++++++++++++++++++++------------ hate_crack/attacks.py | 93 +++++++++++++++++++++++----------- hate_crack/cli.py | 4 +- hate_crack/main.py | 33 +++++++----- hate_crack/username_detect.py | 75 ++++++++++++++++++---------- 6 files changed, 196 insertions(+), 107 deletions(-) diff --git a/hate_crack/__init__.py b/hate_crack/__init__.py index 9741c5a..0543c68 100644 --- a/hate_crack/__init__.py +++ b/hate_crack/__init__.py @@ -12,8 +12,6 @@ except _PackageNotFoundError: # "2.5.1.post1.dev0" → "2.5.1" # "2.5.1" → "2.5.1" __version__ = _re.sub(r"(\.post\d+|\.dev\d+)", "", _raw_version) -__version_tuple__ = tuple( - int(x) if x.isdigit() else x for x in __version__.split(".") -) +__version_tuple__ = tuple(int(x) if x.isdigit() else x for x in __version__.split(".")) __all__ = ["__version__", "__version_tuple__"] diff --git a/hate_crack/api.py b/hate_crack/api.py index 767657a..5160e97 100644 --- a/hate_crack/api.py +++ b/hate_crack/api.py @@ -129,7 +129,9 @@ def _streamed_download( allow_redirects=allow_redirects, ) as r: r.raise_for_status() - return _stream_response_to_file(r, dest_path, label=label, show_progress=show_progress) + return _stream_response_to_file( + r, dest_path, label=label, show_progress=show_progress + ) except KeyboardInterrupt: raise except Exception as e: @@ -407,8 +409,10 @@ class TransmissionSession: percent_done = 0.0 # Status is tokens[7] (best-effort); name is the rest. status = tokens[7] if len(tokens) > 8 else "" - name = " ".join(tokens[8:]) if len(tokens) > 8 else ( - tokens[-1] if len(tokens) > 1 else "" + name = ( + " ".join(tokens[8:]) + if len(tokens) > 8 + else (tokens[-1] if len(tokens) > 1 else "") ) entries.append( { @@ -486,10 +490,7 @@ class TransmissionSession: if not entries: break for entry in entries: - if ( - entry["percent_done"] >= 100.0 - and entry["id"] not in completed_ids - ): + if entry["percent_done"] >= 100.0 and entry["id"] not in completed_ids: completed_ids.add(entry["id"]) file_name = self.info_file(entry["id"]) on_complete(entry["id"], file_name) @@ -627,9 +628,7 @@ def run_torrent_session(torrent_files, save_dir, *, print_fn=print) -> None: failed += 1 return abs_path = ( - file_path - if os.path.isabs(file_path) - else os.path.join(save_dir, file_path) + file_path if os.path.isabs(file_path) else os.path.join(save_dir, file_path) ) if abs_path.endswith(".7z"): ok = extract_with_7z(abs_path, save_dir, remove_archive=True) @@ -653,9 +652,7 @@ def run_torrent_session(torrent_files, save_dir, *, print_fn=print) -> None: except KeyboardInterrupt: print_fn("\n[!] Torrent download interrupted.") raise - print_fn( - f"[i] Torrent session complete: {completed} succeeded, {failed} failed." - ) + print_fn(f"[i] Torrent session complete: {completed} succeeded, {failed} failed.") def fetch_all_weakpass_wordlists_multithreaded(total_pages=None, threads=10): @@ -678,10 +675,9 @@ def fetch_all_weakpass_wordlists_multithreaded(total_pages=None, threads=10): last_page = None if isinstance(wordlists_raw, dict): # Check multiple possible locations for last_page - last_page = ( - wordlists_raw.get("last_page") - or wordlists_raw.get("meta", {}).get("last_page") - ) + last_page = wordlists_raw.get("last_page") or wordlists_raw.get( + "meta", {} + ).get("last_page") if "data" in wordlists_raw: wordlists_raw = wordlists_raw["data"] else: @@ -729,7 +725,9 @@ def fetch_all_weakpass_wordlists_multithreaded(total_pages=None, threads=10): seen.add(wl["name"]) return result else: - print("[!] Weakpass page 1 returned no results; falling back to 67 pages") + print( + "[!] Weakpass page 1 returned no results; falling back to 67 pages" + ) total_pages = 67 entries1 = [] except Exception as e: @@ -892,7 +890,8 @@ def fetch_torrent_metadata(torrent_url, save_dir=None, wordlist_id=None): r2 = requests.get(torrent_link, headers=headers, stream=True) content_type = r2.headers.get("Content-Type", "") local_filename = os.path.join( - torrent_dir, filename if filename.endswith(".torrent") else filename + ".torrent" + torrent_dir, + filename if filename.endswith(".torrent") else filename + ".torrent", ) if r2.status_code == 200 and not content_type.startswith("text/html"): with open(local_filename, "wb") as f: @@ -1010,7 +1009,9 @@ def weakpass_wordlist_menu(rank=-1): if not torrent_url: print(f"[!] Missing torrent URL for selection {idx}") continue - meta = fetch_torrent_metadata(torrent_url, save_dir=save_dir, wordlist_id=entry.get("id")) + meta = fetch_torrent_metadata( + torrent_url, save_dir=save_dir, wordlist_id=entry.get("id") + ) if meta: torrent_files.append(meta) if torrent_files: @@ -1352,7 +1353,9 @@ class HashviewAPI: all_hashfiles = self.get_hashfiles_by_type(hash_type) customer_hfs = [ - hf for hf in all_hashfiles if int(hf.get("customer_id", 0)) == int(customer_id) + hf + for hf in all_hashfiles + if int(hf.get("customer_id", 0)) == int(customer_id) ] # The type-scoped endpoint already returns the hash_type, but normalize @@ -1570,7 +1573,12 @@ class HashviewAPI: return resp.json() def download_left_hashes( - self, customer_id, hashfile_id, output_file=None, hash_type=None, potfile_path=None + self, + customer_id, + hashfile_id, + output_file=None, + hash_type=None, + potfile_path=None, ): import sys @@ -1676,7 +1684,11 @@ class HashviewAPI: ) # Append found hash:clear pairs to the potfile - resolved_potfile = potfile_path if potfile_path is not None else get_hcat_potfile_path() + resolved_potfile = ( + potfile_path + if potfile_path is not None + else get_hcat_potfile_path() + ) if resolved_potfile: appended = 0 with open(resolved_potfile, "a", encoding="utf-8") as pf: @@ -1810,7 +1822,9 @@ class HashviewAPI: r"filename=\"?([^\";]+)\"?", content_disp, re.IGNORECASE ) output_file = ( - os.path.basename(match.group(1)) if match else f"wordlist_{wordlist_id}.gz" + os.path.basename(match.group(1)) + if match + else f"wordlist_{wordlist_id}.gz" ) if not os.path.isabs(output_file): @@ -2155,7 +2169,9 @@ def download_hashmob_wordlist(file_name, out_path): def _attempt(): _hashmob_limiter.wait() - with requests.get(url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r: + with requests.get( + url, headers=headers, stream=True, timeout=60, allow_redirects=True + ) as r: if r.status_code == 429: raise _Hashmob429() r.raise_for_status() @@ -2171,7 +2187,9 @@ def download_hashmob_wordlist(file_name, out_path): real_url = match.group(1) print(f"Found meta refresh redirect to: {real_url}") return _streamed_download(real_url, out_path, label=file_name) - print("Error: Received HTML instead of file. Possible permission or quota issue.") + print( + "Error: Received HTML instead of file. Possible permission or quota issue." + ) return False return _stream_response_to_file(r, out_path, label=file_name) @@ -2281,19 +2299,31 @@ def download_hashmob_rule(file_name, out_path): print( f"[i] Hashmob rule not in pinned URL list, using public prefix: {file_name}" ) - primary_url = f"https://www.hashmob.net/api/v2/downloads/research/rules/{file_name}" + primary_url = ( + f"https://www.hashmob.net/api/v2/downloads/research/rules/{file_name}" + ) alt_url = f"https://hashmob.net/api/v2/downloads/research/official/hashmob_rules/{file_name}" api_key = get_hashmob_api_key() headers = {"api-key": api_key} if api_key else {} def _attempt(): _hashmob_limiter.wait() - with requests.get(primary_url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r: + with requests.get( + primary_url, headers=headers, stream=True, timeout=60, allow_redirects=True + ) as r: if r.status_code == 429: raise _Hashmob429() if r.status_code == 404 and alt_url: - print(f"[i] Hashmob rule not found at primary URL, trying fallback: {alt_url}") - with requests.get(alt_url, headers=headers, stream=True, timeout=60, allow_redirects=True) as r2: + print( + f"[i] Hashmob rule not found at primary URL, trying fallback: {alt_url}" + ) + with requests.get( + alt_url, + headers=headers, + stream=True, + timeout=60, + allow_redirects=True, + ) as r2: if r2.status_code == 429: raise _Hashmob429() r2.raise_for_status() @@ -2554,9 +2584,7 @@ def download_official_wordlist(file_name, out_path): out_path = sanitize_filename(file_name) dest_dir = get_hcat_wordlists_dir() archive_path = ( - os.path.join(dest_dir, out_path) - if not os.path.isabs(out_path) - else out_path + os.path.join(dest_dir, out_path) if not os.path.isabs(out_path) else out_path ) os.makedirs(os.path.dirname(archive_path), exist_ok=True) ok = _streamed_download(url, archive_path, label=file_name) diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index f1e704c..ce3f1d4 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -170,9 +170,7 @@ def quick_crack(ctx: Any) -> None: if raw_choice == "": wordlist_choice = default_dir elif raw_choice.isdigit() and 1 <= int(raw_choice) <= len(wordlist_files): - chosen = os.path.join( - list_dir, wordlist_files[int(raw_choice) - 1] - ) + chosen = os.path.join(list_dir, wordlist_files[int(raw_choice) - 1]) if os.path.exists(chosen): wordlist_choice = chosen print(wordlist_choice) @@ -250,9 +248,7 @@ def extensive_crack(ctx: Any) -> None: ctx.hcatGoodMeasure(ctx.hcatHashType, ctx.hcatHashFile) ctx.hcatRecycle(ctx.hcatHashType, ctx.hcatHashFile, ctx.hcatExtraCount) cracked_after = ctx.lineCount(out_path) if os.path.exists(out_path) else 0 - _notify.notify_job_done( - "Extensive Crack", cracked_after, ctx.hcatHashFile - ) + _notify.notify_job_done("Extensive Crack", cracked_after, ctx.hcatHashFile) # Note: ``cracked_before`` is tracked for potential future per-orchestrator # delta reporting, but today the notify message uses the absolute count # because that matches what single-attack notifications already report. @@ -308,7 +304,9 @@ def combinator_crack(ctx: Any) -> None: print("\n" + "=" * 60) print("COMBINATOR ATTACK") print("=" * 60) - print("Combines 2-8 wordlists. 2 uses hashcat native mode; 3+ use external binaries.") + print( + "Combines 2-8 wordlists. 2 uses hashcat native mode; 3+ use external binaries." + ) print("=" * 60) use_default = ( @@ -318,7 +316,9 @@ def combinator_crack(ctx: Any) -> None: if use_default != "n": base = ctx.hcatCombinationWordlist wordlists = base if isinstance(base, list) else [base] - wordlists = [ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists] + wordlists = [ + ctx._resolve_wordlist_path(wl, ctx.hcatWordlists) for wl in wordlists + ] if len(wordlists) < 2: print("\n[!] Config does not have at least 2 wordlists.") print("Set hcatCombinationWordlist to a list of 2+ paths in config.json.") @@ -332,14 +332,18 @@ def combinator_crack(ctx: Any) -> None: print("\n[!] Combinator attack requires at least 2 wordlists.") print("Aborting combinator attack.") return - separator = input("\nEnter separator between words (leave blank for none): ").strip() + separator = input( + "\nEnter separator between words (leave blank for none): " + ).strip() if len(wordlists) == 2 and not separator: ctx.hcatCombination(ctx.hcatHashType, ctx.hcatHashFile, wordlists) elif len(wordlists) == 3 and not separator: ctx.hcatCombinator3(ctx.hcatHashType, ctx.hcatHashFile, wordlists) else: - ctx.hcatCombinatorX(ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None) + ctx.hcatCombinatorX( + ctx.hcatHashType, ctx.hcatHashFile, wordlists, separator or None + ) def hybrid_crack(ctx: Any) -> None: @@ -568,7 +572,9 @@ def ollama_attack(ctx: Any) -> None: items.append(("99", "Cancel")) while True: - choice = interactive_menu(items, title="\nLLM Attack", prompt="\n\tSelect generation mode: ") + choice = interactive_menu( + items, title="\nLLM Attack", prompt="\n\tSelect generation mode: " + ) if choice is None or choice == "99": return if choice == "1": @@ -656,7 +662,11 @@ def omen_attack(ctx: Any) -> None: ("99", "Cancel"), ] while True: - choice = interactive_menu(model_items, title="\nOMEN Attack (Ordered Markov ENumerator)", prompt="\n\tChoice: ") + choice = interactive_menu( + model_items, + title="\nOMEN Attack (Ordered Markov ENumerator)", + prompt="\n\tChoice: ", + ) if choice is None or choice == "99": return if choice == "1": @@ -801,7 +811,7 @@ def combipow_crack(ctx: Any) -> None: if not os.path.isfile(path): print(f"[!] File not found: {path}") continue - with (gzip.open(path, "rb") if path.endswith(".gz") else open(path, "rb")) as fh: + with gzip.open(path, "rb") if path.endswith(".gz") else open(path, "rb") as fh: line_count = sum(1 for _ in fh) if line_count > 63: print( @@ -823,7 +833,9 @@ def generate_rules_crack(ctx: Any) -> None: print("RANDOM RULES ATTACK") print("=" * 60) print("Generates random hashcat mutation rules and applies them to a wordlist.") - print("Use when known rulesets are exhausted - a chaos mode for rule-space exploration.") + print( + "Use when known rulesets are exhausted - a chaos mode for rule-space exploration." + ) print("=" * 60) raw_count = input("\nNumber of random rules to generate (65536): ").strip() @@ -893,7 +905,9 @@ def generate_rules_crack(ctx: Any) -> None: except ValueError: print("Please enter a valid number.") - ctx.hcatGenerateRules(ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice) + ctx.hcatGenerateRules( + ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice + ) def ngram_attack(ctx: Any) -> None: @@ -929,7 +943,9 @@ def permute_crack(ctx: Any) -> None: print("PERMUTATION ATTACK") print("=" * 60) print("Generates ALL character permutations of each word in a targeted wordlist.") - print("WARNING: Scales as N! per word. Only practical for words up to ~8 characters.") + print( + "WARNING: Scales as N! per word. Only practical for words up to ~8 characters." + ) print("Best for: short targeted wordlists (names, abbreviations, known fragments).") print("=" * 60) @@ -955,9 +971,7 @@ def permute_crack(ctx: Any) -> None: wordlist_path = None while wordlist_path is None: - raw = input( - "\nEnter path to a wordlist FILE (tab to autocomplete): " - ).strip() + raw = input("\nEnter path to a wordlist FILE (tab to autocomplete): ").strip() if not raw: continue if not os.path.exists(raw): @@ -971,7 +985,6 @@ def permute_crack(ctx: Any) -> None: ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path) - def combinator_submenu(ctx: Any) -> None: items = [ ("1", "Combinator Attack (2-8 wordlists)"), @@ -1114,7 +1127,9 @@ def wordlist_filter_length(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return @@ -1134,11 +1149,15 @@ def wordlist_filter_charclass_include(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return - print("[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)") + print( + "[*] Char class mask: 1=lowercase, 2=uppercase, 4=digit, 8=symbol (additive, e.g. 3=lower+upper)" + ) mask = int(input("Mask value: ").strip() or "0") if ctx.wordlist_filter_req_include(infile, outfile, mask): print(f"\n[*] Filtered wordlist written to: {outfile}") @@ -1154,7 +1173,9 @@ def wordlist_filter_charclass_exclude(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return @@ -1174,7 +1195,9 @@ def wordlist_cut_substring(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return @@ -1195,7 +1218,9 @@ def wordlist_split_by_length(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outdir = ctx.select_file_with_autocomplete("[*] Enter output directory path").strip() + outdir = ctx.select_file_with_autocomplete( + "[*] Enter output directory path" + ).strip() if not outdir: print("[!] Output directory cannot be empty.") return @@ -1226,7 +1251,9 @@ def wordlist_subtract_words(ctx: Any) -> None: if not os.path.isfile(remove_file): print(f"[!] File not found: {remove_file}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return @@ -1241,12 +1268,16 @@ def wordlist_subtract_words(ctx: Any) -> None: if not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = ctx.select_file_with_autocomplete("[*] Enter path to output wordlist").strip() + outfile = ctx.select_file_with_autocomplete( + "[*] Enter path to output wordlist" + ).strip() if not outfile: print("[!] Output path cannot be empty.") return raw = ctx.select_file_with_autocomplete( - "[*] Enter remove file paths", allow_multiple=True, base_dir=ctx.hcatWordlists + "[*] Enter remove file paths", + allow_multiple=True, + base_dir=ctx.hcatWordlists, ).strip() remove_files = [r.strip() for r in raw.split(",") if r.strip()] if not remove_files: @@ -1320,7 +1351,9 @@ def wordlist_optimize(ctx: Any) -> None: for p in not_found: print(f" {p}") return - outdir = ctx.select_file_with_autocomplete("[*] Enter output directory path").strip() + outdir = ctx.select_file_with_autocomplete( + "[*] Enter output directory path" + ).strip() if not outdir: print("[!] Output directory cannot be empty.") return diff --git a/hate_crack/cli.py b/hate_crack/cli.py index 4cfcd67..b23a514 100644 --- a/hate_crack/cli.py +++ b/hate_crack/cli.py @@ -55,7 +55,9 @@ def setup_logging(logger: logging.Logger, hate_path: str, debug_mode: bool) -> N logger.addHandler(stream_handler) # Show HTTP requests made by the requests/urllib3 library. debug_handler = logging.StreamHandler(sys.stderr) - debug_handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")) + debug_handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") + ) urllib3_logger = logging.getLogger("urllib3") urllib3_logger.setLevel(logging.DEBUG) urllib3_logger.addHandler(debug_handler) diff --git a/hate_crack/main.py b/hate_crack/main.py index 6018b51..fb67a85 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -479,7 +479,9 @@ omenTrainingList = config_parser.get("omenTrainingList", "rockyou.txt") omenMaxCandidates = int(config_parser.get("omenMaxCandidates", 1000000)) pcfgRuleset = config_parser.get("pcfgRuleset", "DEFAULT") pcfgMaxCandidates = int(config_parser.get("pcfgMaxCandidates", 50000000)) -pcfgPrinceLingMaxCandidates = int(config_parser.get("pcfgPrinceLingMaxCandidates", 10000000)) +pcfgPrinceLingMaxCandidates = int( + config_parser.get("pcfgPrinceLingMaxCandidates", 10000000) +) try: _cfg_optimized = config_parser["optimizedKernelAttacks"] @@ -746,9 +748,15 @@ if not SKIP_INIT: # Verify pcfg_cracker presence (optional, for PCFG attacks) # pcfg_cracker is pure-Python; we just check the script files exist. pcfg_guesser_script = os.path.join(hate_path, "pcfg_cracker", "pcfg_guesser.py") - pcfg_prince_ling_script = os.path.join(hate_path, "pcfg_cracker", "prince_ling.py") - if not os.path.isfile(pcfg_guesser_script) or not os.path.isfile(pcfg_prince_ling_script): - print("pcfg_cracker not found at " + os.path.join(hate_path, "pcfg_cracker")) + pcfg_prince_ling_script = os.path.join( + hate_path, "pcfg_cracker", "prince_ling.py" + ) + if not os.path.isfile(pcfg_guesser_script) or not os.path.isfile( + pcfg_prince_ling_script + ): + print( + "pcfg_cracker not found at " + os.path.join(hate_path, "pcfg_cracker") + ) print("PCFG attacks will not be available. Run 'make' to fetch submodules.") elif not shutil.which("python3"): print("python3 not on PATH. PCFG attacks will not be available.") @@ -2818,8 +2826,11 @@ def hcatPrinceLing(hcatHashType, hcatHashFile): print(f"PCFG ruleset not found: {ruleset_dir}") return - cache_dir = hcatOptimizedWordlists if isinstance(hcatOptimizedWordlists, str) \ + cache_dir = ( + hcatOptimizedWordlists + if isinstance(hcatOptimizedWordlists, str) else str(hcatOptimizedWordlists) + ) os.makedirs(cache_dir, exist_ok=True) cache_path = os.path.join(cache_dir, f"pcfg_prince_ling_{pcfgRuleset}.txt") tmp_path = cache_path + ".tmp" @@ -3664,9 +3675,7 @@ def hashview_api(): or api_name ) try: - download_result = api_harness.download_rules( - rules_id, output_file - ) + download_result = api_harness.download_rules(rules_id, output_file) print(f"\n✓ Success: Downloaded {download_result['size']} bytes") print(f" File: {download_result['output_file']}") except Exception as e: @@ -3936,8 +3945,8 @@ def hashview_api(): print( "\nScanning customer hashfiles across common hash types..." ) - customer_hashfiles = ( - api_harness.get_all_customer_hashfiles(customer_id) + customer_hashfiles = api_harness.get_all_customer_hashfiles( + customer_id ) except Exception as e: customer_hashfiles = [] @@ -4981,9 +4990,7 @@ def main(): else: argv = argv_temp # Fallback if subcommand not found - has_attack_subcommand = any( - arg in _noninteractive.ATTACK_COMMANDS for arg in argv - ) + has_attack_subcommand = any(arg in _noninteractive.ATTACK_COMMANDS for arg in argv) use_subcommand_parser = "hashview" in argv or has_attack_subcommand parser, hashview_parser = _build_parser( include_positional=not use_subcommand_parser, diff --git a/hate_crack/username_detect.py b/hate_crack/username_detect.py index f334e5f..7ea8038 100644 --- a/hate_crack/username_detect.py +++ b/hate_crack/username_detect.py @@ -3,6 +3,7 @@ This module owns the allowlist/blocklist of hash modes and the regex-based per-line validation used to decide whether to pass ``--username`` to hashcat. """ + from __future__ import annotations import re @@ -11,39 +12,59 @@ from typing import Final # Modes where bare hashes are the normal input AND files commonly carry a # ``username:`` prefix. Value is the expected hex length of the hash field. USERNAME_HASH_MODES: Final[dict[str, int]] = { - "0": 32, # MD5 - "10": 32, # md5($pass.$salt) - "20": 32, # md5($salt.$pass) - "30": 32, # md5(unicode($pass).$salt) - "40": 32, # md5($salt.unicode($pass)) - "50": 32, # HMAC-MD5 - "60": 32, # HMAC-MD5(key=$pass) - "100": 40, # SHA1 - "101": 40, # nsldap/SHA1(Base64) - "110": 40, # sha1($pass.$salt) - "120": 40, # sha1($salt.$pass) - "130": 40, # sha1(unicode($pass).$salt) - "140": 40, # sha1($salt.unicode($pass)) - "150": 40, # HMAC-SHA1 - "160": 40, # HMAC-SHA1(key=$pass) - "900": 32, # MD4 - "1000": 32, # NTLM bare - "1400": 64, # SHA2-256 - "1410": 64, "1420": 64, "1430": 64, "1440": 64, "1450": 64, "1460": 64, + "0": 32, # MD5 + "10": 32, # md5($pass.$salt) + "20": 32, # md5($salt.$pass) + "30": 32, # md5(unicode($pass).$salt) + "40": 32, # md5($salt.unicode($pass)) + "50": 32, # HMAC-MD5 + "60": 32, # HMAC-MD5(key=$pass) + "100": 40, # SHA1 + "101": 40, # nsldap/SHA1(Base64) + "110": 40, # sha1($pass.$salt) + "120": 40, # sha1($salt.$pass) + "130": 40, # sha1(unicode($pass).$salt) + "140": 40, # sha1($salt.unicode($pass)) + "150": 40, # HMAC-SHA1 + "160": 40, # HMAC-SHA1(key=$pass) + "900": 32, # MD4 + "1000": 32, # NTLM bare + "1400": 64, # SHA2-256 + "1410": 64, + "1420": 64, + "1430": 64, + "1440": 64, + "1450": 64, + "1460": 64, "1700": 128, # SHA2-512 - "1710": 128, "1720": 128, "1730": 128, "1740": 128, "1750": 128, "1760": 128, - "3000": 16, # LM (single half) + "1710": 128, + "1720": 128, + "1730": 128, + "1740": 128, + "1750": 128, + "1760": 128, + "3000": 16, # LM (single half) } # Modes explicitly excluded even if they appear in allowlist (they don't, but # this constant is the documentation of the intentional blocklist). Binary # formats, IKE-PSK, and NetNTLM (already preprocessed elsewhere). -USERNAME_DETECT_BLOCKLIST: Final[frozenset[str]] = frozenset({ - "2500", "22000", "2501", "16800", "16801", "22001", # WPA variants (binary) - "5300", "5400", # IKE-PSK - "5500", "5600", # NetNTLM (own preprocess) - "1800", "3200", # non-hex hash formats -}) +USERNAME_DETECT_BLOCKLIST: Final[frozenset[str]] = frozenset( + { + "2500", + "22000", + "2501", + "16800", + "16801", + "22001", # WPA variants (binary) + "5300", + "5400", # IKE-PSK + "5500", + "5600", # NetNTLM (own preprocess) + "1800", + "3200", # non-hex hash formats + } +) def detect_username_hash_format( From 4418c4e4d6a0e0d7eff8f379a2bfaf49a2c38b33 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Jul 2026 13:21:22 -0400 Subject: [PATCH 44/51] ci: mirror hashview lint/security gates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the gates the hashview repo runs, adapted to hate_crack's uv toolchain: - ruff format --check as a CI step + a ruff-format prek pre-push hook (the package was reformatted in the preceding commit). - Bandit SAST vs a committed baseline (.bandit-baseline.json, 114 reviewed low-severity subprocess/shlex findings); [tool.bandit] config in pyproject.toml; CI job + prek pre-push hook. Only NEW findings fail. - pip-audit dependency-CVE gate (CI job). PYSEC-2026-2447 (diskcache 5.6.3, transitive via instructor -> atomic-agents) is ignored — no upstream fix. - Hygiene pre-commit hooks (trailing-whitespace, end-of-file-fixer, check-yaml, check-merge-conflict, check-added-large-files) via the pre-commit-hooks repo. - CLAUDE.md updated: lint/security commands, prek install (+pre-commit hook type), and the active-hooks list. Co-Authored-By: Claude --- .bandit-baseline.json | 2837 ++++++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 50 +- prek.toml | 41 + pyproject.toml | 17 + 4 files changed, 2944 insertions(+), 1 deletion(-) create mode 100644 .bandit-baseline.json diff --git a/.bandit-baseline.json b/.bandit-baseline.json new file mode 100644 index 0000000..3112a5b --- /dev/null +++ b/.bandit-baseline.json @@ -0,0 +1,2837 @@ +{ + "errors": [], + "generated_at": "2026-07-25T17:17:25Z", + "metrics": { + "_totals": { + "CONFIDENCE.HIGH": 112, + "CONFIDENCE.LOW": 2, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 4, + "SEVERITY.LOW": 108, + "SEVERITY.MEDIUM": 2, + "SEVERITY.UNDEFINED": 0, + "loc": 9529, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/__init__.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 10, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/__main__.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 6, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/api.py": { + "CONFIDENCE.HIGH": 42, + "CONFIDENCE.LOW": 2, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 2, + "SEVERITY.LOW": 40, + "SEVERITY.MEDIUM": 2, + "SEVERITY.UNDEFINED": 0, + "loc": 2352, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/attacks.py": { + "CONFIDENCE.HIGH": 4, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 4, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 1214, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/cli.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 51, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/formatting.py": { + "CONFIDENCE.HIGH": 2, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 2, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 47, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/llm.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 260, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/main.py": { + "CONFIDENCE.HIGH": 63, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 2, + "SEVERITY.LOW": 61, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 4630, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/menu.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 69, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/noninteractive.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 122, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/__init__.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 246, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/_suppress.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 28, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/pushover.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 52, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/settings.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 141, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/notify/tailer.py": { + "CONFIDENCE.HIGH": 1, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 1, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 156, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/progress.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 53, + "nosec": 0, + "skipped_tests": 0 + }, + "hate_crack/username_detect.py": { + "CONFIDENCE.HIGH": 0, + "CONFIDENCE.LOW": 0, + "CONFIDENCE.MEDIUM": 0, + "CONFIDENCE.UNDEFINED": 0, + "SEVERITY.HIGH": 0, + "SEVERITY.LOW": 0, + "SEVERITY.MEDIUM": 0, + "SEVERITY.UNDEFINED": 0, + "loc": 92, + "nosec": 0, + "skipped_tests": 0 + } + }, + "results": [ + { + "code": "57 total = int(r.headers.get(\"content-length\") or 0)\n58 except Exception:\n59 pass\n60 downloaded = 0\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 58, + "line_range": [ + 58, + 59 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "96 os.remove(temp_path)\n97 except Exception:\n98 pass\n99 return False\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 97, + "line_range": [ + 97, + 98 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "270 import atexit\n271 import subprocess\n272 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 271, + "line_range": [ + 271 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "275 self._rpc = f\"127.0.0.1:{self._port}\"\n276 self._proc = subprocess.Popen(\n277 [\n278 \"transmission-daemon\",\n279 \"-f\",\n280 \"-g\",\n281 self._cfg_dir,\n282 \"--port\",\n283 str(self._port),\n284 \"--rpc-bind-address\",\n285 \"127.0.0.1\",\n286 \"--no-auth\",\n287 \"--download-dir\",\n288 self.save_dir,\n289 \"--no-portmap\",\n290 \"--no-watch-dir\",\n291 ],\n292 stdout=subprocess.DEVNULL,\n293 stderr=subprocess.DEVNULL,\n294 )\n295 deadline = time.monotonic() + self.startup_timeout\n", + "col_offset": 21, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 276, + "line_range": [ + 276, + 277, + 278, + 279, + 280, + 281, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 294 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "275 self._rpc = f\"127.0.0.1:{self._port}\"\n276 self._proc = subprocess.Popen(\n277 [\n278 \"transmission-daemon\",\n279 \"-f\",\n280 \"-g\",\n281 self._cfg_dir,\n282 \"--port\",\n283 str(self._port),\n284 \"--rpc-bind-address\",\n285 \"127.0.0.1\",\n286 \"--no-auth\",\n287 \"--download-dir\",\n288 self.save_dir,\n289 \"--no-portmap\",\n290 \"--no-watch-dir\",\n291 ],\n292 stdout=subprocess.DEVNULL,\n293 stderr=subprocess.DEVNULL,\n294 )\n295 deadline = time.monotonic() + self.startup_timeout\n", + "col_offset": 21, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 276, + "line_range": [ + 276, + 277, + 278, + 279, + 280, + 281, + 282, + 283, + 284, + 285, + 286, + 287, + 288, + 289, + 290, + 291, + 292, + 293, + 294 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "296 while time.monotonic() < deadline:\n297 probe = subprocess.run(\n298 [\"transmission-remote\", self._rpc, \"-l\"],\n299 capture_output=True,\n300 )\n301 if probe.returncode == 0:\n", + "col_offset": 20, + "end_col_offset": 13, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 297, + "line_range": [ + 297, + 298, + 299, + 300 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "296 while time.monotonic() < deadline:\n297 probe = subprocess.run(\n298 [\"transmission-remote\", self._rpc, \"-l\"],\n299 capture_output=True,\n300 )\n301 if probe.returncode == 0:\n", + "col_offset": 20, + "end_col_offset": 13, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 297, + "line_range": [ + 297, + 298, + 299, + 300 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "310 def _stop(self):\n311 import subprocess\n312 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 311, + "line_range": [ + 311 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "317 try:\n318 subprocess.run(\n319 [\"transmission-remote\", self._rpc, \"--exit\"],\n320 capture_output=True,\n321 )\n322 except Exception:\n", + "col_offset": 16, + "end_col_offset": 17, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 318, + "line_range": [ + 318, + 319, + 320, + 321 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "317 try:\n318 subprocess.run(\n319 [\"transmission-remote\", self._rpc, \"--exit\"],\n320 capture_output=True,\n321 )\n322 except Exception:\n", + "col_offset": 16, + "end_col_offset": 17, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 318, + "line_range": [ + 318, + 319, + 320, + 321 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "321 )\n322 except Exception:\n323 pass\n324 if self._proc is not None:\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 322, + "line_range": [ + 322, + 323 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "333 self._proc.kill()\n334 except Exception:\n335 pass\n336 except Exception:\n", + "col_offset": 16, + "end_col_offset": 24, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 334, + "line_range": [ + 334, + 335 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "335 pass\n336 except Exception:\n337 pass\n338 if self._cfg_dir:\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 336, + "line_range": [ + 336, + 337 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "346 import re\n347 import subprocess\n348 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 347, + "line_range": [ + 347 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "349 before_ids = {e[\"id\"] for e in self.list()}\n350 result = subprocess.run(\n351 [\n352 \"transmission-remote\",\n353 self._rpc,\n354 \"-a\",\n355 torrent_path,\n356 ],\n357 capture_output=True,\n358 text=True,\n359 )\n360 out = result.stdout or \"\"\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 350, + "line_range": [ + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 359 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "349 before_ids = {e[\"id\"] for e in self.list()}\n350 result = subprocess.run(\n351 [\n352 \"transmission-remote\",\n353 self._rpc,\n354 \"-a\",\n355 torrent_path,\n356 ],\n357 capture_output=True,\n358 text=True,\n359 )\n360 out = result.stdout or \"\"\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 350, + "line_range": [ + 350, + 351, + 352, + 353, + 354, + 355, + 356, + 357, + 358, + 359 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "373 def list(self) -> list:\n374 import subprocess\n375 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 374, + "line_range": [ + 374 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "375 \n376 result = subprocess.run(\n377 [\"transmission-remote\", self._rpc, \"-l\"],\n378 capture_output=True,\n379 text=True,\n380 )\n381 if result.returncode != 0:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 376, + "line_range": [ + 376, + 377, + 378, + 379, + 380 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "375 \n376 result = subprocess.run(\n377 [\"transmission-remote\", self._rpc, \"-l\"],\n378 capture_output=True,\n379 text=True,\n380 )\n381 if result.returncode != 0:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 376, + "line_range": [ + 376, + 377, + 378, + 379, + 380 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "429 def info_file(self, torrent_id: int) -> str:\n430 import subprocess\n431 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 430, + "line_range": [ + 430 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "431 \n432 result = subprocess.run(\n433 [\n434 \"transmission-remote\",\n435 self._rpc,\n436 f\"-t{torrent_id}\",\n437 \"--info-files\",\n438 ],\n439 capture_output=True,\n440 text=True,\n441 )\n442 if result.returncode != 0:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 432, + "line_range": [ + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 439, + 440, + 441 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "431 \n432 result = subprocess.run(\n433 [\n434 \"transmission-remote\",\n435 self._rpc,\n436 f\"-t{torrent_id}\",\n437 \"--info-files\",\n438 ],\n439 capture_output=True,\n440 text=True,\n441 )\n442 if result.returncode != 0:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 432, + "line_range": [ + 432, + 433, + 434, + 435, + 436, + 437, + 438, + 439, + 440, + 441 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "472 def remove(self, torrent_id: int):\n473 import subprocess\n474 \n", + "col_offset": 8, + "end_col_offset": 25, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 473, + "line_range": [ + 473 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "474 \n475 subprocess.run(\n476 [\n477 \"transmission-remote\",\n478 self._rpc,\n479 f\"-t{torrent_id}\",\n480 \"--remove\",\n481 ],\n482 capture_output=True,\n483 )\n484 \n", + "col_offset": 8, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 475, + "line_range": [ + 475, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "474 \n475 subprocess.run(\n476 [\n477 \"transmission-remote\",\n478 self._rpc,\n479 f\"-t{torrent_id}\",\n480 \"--remove\",\n481 ],\n482 capture_output=True,\n483 )\n484 \n", + "col_offset": 8, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 475, + "line_range": [ + 475, + 476, + 477, + 478, + 479, + 480, + 481, + 482, + 483 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "512 return path\n513 except Exception:\n514 pass\n515 default = os.path.join(os.getcwd(), \"wordlists\")\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 513, + "line_range": [ + 513, + 514 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "532 return path\n533 except Exception:\n534 pass\n535 default = os.path.join(os.getcwd(), \"rules\")\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 533, + "line_range": [ + 533, + 534 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "550 return shlex.split(tuning)\n551 except Exception:\n552 pass\n553 return []\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 551, + "line_range": [ + 551, + 552 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "570 return expanded\n571 except Exception:\n572 pass\n573 return os.path.expanduser(\"~/.hashcat/hashcat.potfile\")\n", + "col_offset": 8, + "end_col_offset": 16, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 571, + "line_range": [ + 571, + 572 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "816 break\n817 except Exception:\n818 continue\n819 if hashmob_api_key:\n", + "col_offset": 12, + "end_col_offset": 24, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 817, + "line_range": [ + 817, + 818 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "838 print(f\"[+] Fetching wordlist page: {wordlist_uri}\")\n839 r = requests.get(wordlist_uri, headers=headers)\n840 if r.status_code != 200:\n", + "col_offset": 12, + "end_col_offset": 55, + "filename": "hate_crack/api.py", + "issue_confidence": "LOW", + "issue_cwe": { + "id": 400, + "link": "https://cwe.mitre.org/data/definitions/400.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Call to requests without timeout", + "line_number": 839, + "line_range": [ + 839 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b113_request_without_timeout.html", + "test_id": "B113", + "test_name": "request_without_timeout" + }, + { + "code": "889 print(f\"[+] Downloading .torrent file from: {torrent_link}\")\n890 r2 = requests.get(torrent_link, headers=headers, stream=True)\n891 content_type = r2.headers.get(\"Content-Type\", \"\")\n", + "col_offset": 9, + "end_col_offset": 65, + "filename": "hate_crack/api.py", + "issue_confidence": "LOW", + "issue_cwe": { + "id": 400, + "link": "https://cwe.mitre.org/data/definitions/400.html" + }, + "issue_severity": "MEDIUM", + "issue_text": "Call to requests without timeout", + "line_number": 890, + "line_range": [ + 890 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b113_request_without_timeout.html", + "test_id": "B113", + "test_name": "request_without_timeout" + }, + { + "code": "972 indices.update(range(start, end + 1))\n973 except Exception:\n974 continue\n975 else:\n", + "col_offset": 16, + "end_col_offset": 28, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 973, + "line_range": [ + 973, + 974 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "977 indices.add(int(part))\n978 except Exception:\n979 continue\n980 return sorted(i for i in indices if 1 <= i <= max_index)\n", + "col_offset": 16, + "end_col_offset": 28, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 978, + "line_range": [ + 978, + 979 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "1104 if ht == \"0\":\n1105 return hashlib.md5(raw).hexdigest()\n1106 if ht == \"100\":\n", + "col_offset": 15, + "end_col_offset": 31, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 327, + "link": "https://cwe.mitre.org/data/definitions/327.html" + }, + "issue_severity": "HIGH", + "issue_text": "Use of weak MD5 hash for security. Consider usedforsecurity=False", + "line_number": 1105, + "line_range": [ + 1105 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b324_hashlib.html", + "test_id": "B324", + "test_name": "hashlib" + }, + { + "code": "1106 if ht == \"100\":\n1107 return hashlib.sha1(raw).hexdigest()\n1108 if ht == \"1400\":\n", + "col_offset": 15, + "end_col_offset": 32, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 327, + "link": "https://cwe.mitre.org/data/definitions/327.html" + }, + "issue_severity": "HIGH", + "issue_text": "Use of weak SHA1 hash for security. Consider usedforsecurity=False", + "line_number": 1107, + "line_range": [ + 1107 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b324_hashlib.html", + "test_id": "B324", + "test_name": "hashlib" + }, + { + "code": "1961 raise\n1962 except Exception:\n1963 # If stdin status can't be determined, continue normally.\n1964 pass\n1965 api_harness = HashviewAPI(hashview_url, hashview_api_key, debug=debug_mode)\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 1962, + "line_range": [ + 1962, + 1963, + 1964 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "2124 return key\n2125 except Exception:\n2126 continue\n2127 return None\n", + "col_offset": 12, + "end_col_offset": 24, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2125, + "line_range": [ + 2125, + 2126 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2449 indices.update(range(start, end + 1))\n2450 except Exception:\n2451 continue\n2452 else:\n", + "col_offset": 20, + "end_col_offset": 32, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2450, + "line_range": [ + 2450, + 2451 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2454 indices.add(int(part))\n2455 except Exception:\n2456 continue\n2457 return sorted(i for i in indices if 1 <= i <= max_index)\n", + "col_offset": 20, + "end_col_offset": 32, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2455, + "line_range": [ + 2455, + 2456 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2518 indices.update(range(start, end + 1))\n2519 except Exception:\n2520 continue\n2521 else:\n", + "col_offset": 16, + "end_col_offset": 28, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2519, + "line_range": [ + 2519, + 2520 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2523 indices.add(int(part))\n2524 except Exception:\n2525 continue\n2526 return sorted(i for i in indices if 1 <= i <= max_index)\n", + "col_offset": 16, + "end_col_offset": 28, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 2524, + "line_range": [ + 2524, + 2525 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "2597 \"\"\"Extract a .7z archive using the 7z or 7za command.\"\"\"\n2598 import subprocess\n2599 \n", + "col_offset": 4, + "end_col_offset": 21, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 2598, + "line_range": [ + 2598 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "2609 print(f\"Extracting {archive_path} to {output_dir} ...\")\n2610 result = subprocess.run(\n2611 [sevenz_bin, \"e\", \"-y\", archive_path],\n2612 capture_output=True,\n2613 text=True,\n2614 cwd=output_dir,\n2615 )\n2616 print(result.stdout)\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/api.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2610, + "line_range": [ + 2610, + 2611, + 2612, + 2613, + 2614, + 2615 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "17 readline.parse_and_bind(\"set completion-query-items -1\")\n18 except Exception:\n19 pass\n20 try:\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/attacks.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 18, + "line_range": [ + 18, + 19 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "21 readline.parse_and_bind(\"tab: complete\")\n22 except Exception:\n23 pass\n24 try:\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/attacks.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 22, + "line_range": [ + 22, + 23 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "25 readline.parse_and_bind(\"bind ^I rl_complete\")\n26 except Exception:\n27 pass\n28 readline.set_completer(completer)\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/attacks.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 26, + "line_range": [ + 26, + 27 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "111 combined_choice = f\"{combined_choice} -r {rule_path}\"\n112 except Exception:\n113 continue\n114 selected_rules.append(combined_choice)\n", + "col_offset": 20, + "end_col_offset": 32, + "filename": "hate_crack/attacks.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 112, + "line_range": [ + 112, + 113 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + }, + { + "code": "8 return width\n9 except Exception:\n10 pass\n11 try:\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/formatting.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 9, + "line_range": [ + 9, + 10 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "14 return width\n15 except Exception:\n16 pass\n17 return default\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/formatting.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 15, + "line_range": [ + 15, + 16 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "19 import signal\n20 import subprocess\n21 import shlex\n", + "col_offset": 0, + "end_col_offset": 17, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 20, + "line_range": [ + 20 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "40 REQUESTS_AVAILABLE = True\n41 except Exception:\n42 pass\n43 \n", + "col_offset": 0, + "end_col_offset": 8, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 41, + "line_range": [ + 41, + 42 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "689 try:\n690 test_result = subprocess.run(\n691 [binary_path],\n692 stdout=subprocess.PIPE,\n693 stderr=subprocess.PIPE,\n694 timeout=2,\n695 )\n696 # Binary should show usage and exit with error code (that's expected)\n", + "col_offset": 30, + "end_col_offset": 17, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 690, + "line_range": [ + 690, + 691, + 692, + 693, + 694, + 695 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "858 popen_kwargs = {\"stdin\": stdin} if stdin is not None else {}\n859 hcatProcess = subprocess.Popen(cmd, **popen_kwargs)\n860 interrupted = False\n", + "col_offset": 18, + "end_col_offset": 55, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 859, + "line_range": [ + 859 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "865 gen.wait()\n866 except Exception:\n867 pass\n868 except KeyboardInterrupt:\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 866, + "line_range": [ + 866, + 867 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "874 gen.kill()\n875 except Exception:\n876 pass\n877 finally:\n", + "col_offset": 12, + "end_col_offset": 20, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 875, + "line_range": [ + 875, + 876 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "1016 \"\"\"Run `git pull && git fetch --tags && make install` in the repo root.\"\"\"\n1017 import subprocess\n1018 \n", + "col_offset": 4, + "end_col_offset": 21, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Consider possible security implications associated with the subprocess module.", + "line_number": 1017, + "line_range": [ + 1017 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_imports.html#b404-import-subprocess", + "test_id": "B404", + "test_name": "blacklist" + }, + { + "code": "1021 # site-packages when installed rather than the source checkout.\n1022 git_root_result = subprocess.run(\n1023 [\"git\", \"rev-parse\", \"--show-toplevel\"],\n1024 cwd=_repo_root,\n1025 capture_output=True,\n1026 text=True,\n1027 )\n1028 if git_root_result.returncode != 0:\n", + "col_offset": 22, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1022, + "line_range": [ + 1022, + 1023, + 1024, + 1025, + 1026, + 1027 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1021 # site-packages when installed rather than the source checkout.\n1022 git_root_result = subprocess.run(\n1023 [\"git\", \"rev-parse\", \"--show-toplevel\"],\n1024 cwd=_repo_root,\n1025 capture_output=True,\n1026 text=True,\n1027 )\n1028 if git_root_result.returncode != 0:\n", + "col_offset": 22, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1022, + "line_range": [ + 1022, + 1023, + 1024, + 1025, + 1026, + 1027 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1039 # there's no origin/main ref to auto-create a tracking branch from.\n1040 fetch_result = subprocess.run(\n1041 [\"git\", \"fetch\", \"--tags\", \"origin\"],\n1042 cwd=repo_root,\n1043 capture_output=True,\n1044 text=True,\n1045 )\n1046 if fetch_result.returncode != 0:\n", + "col_offset": 19, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1040, + "line_range": [ + 1040, + 1041, + 1042, + 1043, + 1044, + 1045 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1039 # there's no origin/main ref to auto-create a tracking branch from.\n1040 fetch_result = subprocess.run(\n1041 [\"git\", \"fetch\", \"--tags\", \"origin\"],\n1042 cwd=repo_root,\n1043 capture_output=True,\n1044 text=True,\n1045 )\n1046 if fetch_result.returncode != 0:\n", + "col_offset": 19, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1040, + "line_range": [ + 1040, + 1041, + 1042, + 1043, + 1044, + 1045 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1062 # local `main` tracking origin/main so future manual pulls also work.\n1063 branch_result = subprocess.run(\n1064 [\"git\", \"symbolic-ref\", \"--short\", \"HEAD\"],\n1065 cwd=repo_root,\n1066 capture_output=True,\n1067 text=True,\n1068 )\n1069 branch = branch_result.stdout.strip() if branch_result.returncode == 0 else \"\"\n", + "col_offset": 20, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1063, + "line_range": [ + 1063, + 1064, + 1065, + 1066, + 1067, + 1068 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1062 # local `main` tracking origin/main so future manual pulls also work.\n1063 branch_result = subprocess.run(\n1064 [\"git\", \"symbolic-ref\", \"--short\", \"HEAD\"],\n1065 cwd=repo_root,\n1066 capture_output=True,\n1067 text=True,\n1068 )\n1069 branch = branch_result.stdout.strip() if branch_result.returncode == 0 else \"\"\n", + "col_offset": 20, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1063, + "line_range": [ + 1063, + 1064, + 1065, + 1066, + 1067, + 1068 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1071 if branch and branch != \"main\":\n1072 status = subprocess.run(\n1073 [\"git\", \"status\", \"--porcelain\"],\n1074 cwd=repo_root,\n1075 capture_output=True,\n1076 text=True,\n1077 )\n1078 if status.stdout.strip():\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1072, + "line_range": [ + 1072, + 1073, + 1074, + 1075, + 1076, + 1077 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1071 if branch and branch != \"main\":\n1072 status = subprocess.run(\n1073 [\"git\", \"status\", \"--porcelain\"],\n1074 cwd=repo_root,\n1075 capture_output=True,\n1076 text=True,\n1077 )\n1078 if status.stdout.strip():\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1072, + "line_range": [ + 1072, + 1073, + 1074, + 1075, + 1076, + 1077 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1086 print(f\"\\n Switching from '{branch}' to 'main' to pick up the release tag...\")\n1087 checkout = subprocess.run(\n1088 # -B creates/resets a local `main` pointing at origin/main so this\n1089 # works whether or not a local `main` already exists (e.g. a stale\n1090 # master-only clone that has never had a main branch).\n1091 [\"git\", \"checkout\", \"-B\", \"main\", \"origin/main\"],\n1092 cwd=repo_root,\n1093 capture_output=True,\n1094 text=True,\n1095 )\n1096 if checkout.returncode != 0:\n", + "col_offset": 19, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1087, + "line_range": [ + 1087, + 1088, + 1089, + 1090, + 1091, + 1092, + 1093, + 1094, + 1095 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1086 print(f\"\\n Switching from '{branch}' to 'main' to pick up the release tag...\")\n1087 checkout = subprocess.run(\n1088 # -B creates/resets a local `main` pointing at origin/main so this\n1089 # works whether or not a local `main` already exists (e.g. a stale\n1090 # master-only clone that has never had a main branch).\n1091 [\"git\", \"checkout\", \"-B\", \"main\", \"origin/main\"],\n1092 cwd=repo_root,\n1093 capture_output=True,\n1094 text=True,\n1095 )\n1096 if checkout.returncode != 0:\n", + "col_offset": 19, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1087, + "line_range": [ + 1087, + 1088, + 1089, + 1090, + 1091, + 1092, + 1093, + 1094, + 1095 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1104 # origin/main rather than a dangling branch.master.merge ref.\n1105 subprocess.run(\n1106 [\"git\", \"branch\", \"--set-upstream-to=origin/main\", \"main\"],\n1107 cwd=repo_root,\n1108 capture_output=True,\n1109 text=True,\n1110 )\n1111 \n", + "col_offset": 8, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1105, + "line_range": [ + 1105, + 1106, + 1107, + 1108, + 1109, + 1110 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1104 # origin/main rather than a dangling branch.master.merge ref.\n1105 subprocess.run(\n1106 [\"git\", \"branch\", \"--set-upstream-to=origin/main\", \"main\"],\n1107 cwd=repo_root,\n1108 capture_output=True,\n1109 text=True,\n1110 )\n1111 \n", + "col_offset": 8, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1105, + "line_range": [ + 1105, + 1106, + 1107, + 1108, + 1109, + 1110 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1123 f\"git pull origin main && git fetch --tags && make install && {uv} sync --reinstall-package hate_crack\",\n1124 shell=True,\n1125 cwd=repo_root,\n1126 )\n1127 if result.returncode != 0:\n1128 print(\"\\n Upgrade failed. Check the output above for errors.\\n\")\n1129 raise SystemExit(1)\n1130 \n1131 print(\"\\n Upgrade complete. Please restart hate_crack.\\n\")\n1132 raise SystemExit(0)\n1133 \n1134 \n1135 def check_for_updates():\n", + "col_offset": 13, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "HIGH", + "issue_text": "subprocess call with shell=True identified, security issue.", + "line_number": 1124, + "line_range": [ + 1116, + 1117, + 1118, + 1119, + 1120, + 1121, + 1122, + 1123, + 1124, + 1125, + 1126 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b602_subprocess_popen_with_shell_equals_true.html", + "test_id": "B602", + "test_name": "subprocess_popen_with_shell_equals_true" + }, + { + "code": "1166 _run_upgrade()\n1167 except Exception:\n1168 pass\n1169 \n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 1167, + "line_range": [ + 1167, + 1168 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "1226 readline.parse_and_bind(\"tab: complete\")\n1227 except Exception:\n1228 pass\n1229 try:\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 1227, + "line_range": [ + 1227, + 1228 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "1230 readline.parse_and_bind(\"bind ^I rl_complete\")\n1231 except Exception:\n1232 pass\n1233 readline.set_completer(path_completer)\n", + "col_offset": 4, + "end_col_offset": 12, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 1231, + "line_range": [ + 1231, + 1232 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "1293 ):\n1294 sort_proc = subprocess.Popen(\n1295 [\"sort\", \"-u\"],\n1296 stdin=subprocess.PIPE,\n1297 stdout=dst,\n1298 text=True,\n1299 env={**os.environ, \"LC_ALL\": \"C\"},\n1300 )\n1301 for line in src:\n", + "col_offset": 24, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1294, + "line_range": [ + 1294, + 1295, + 1296, + 1297, + 1298, + 1299, + 1300 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1293 ):\n1294 sort_proc = subprocess.Popen(\n1295 [\"sort\", \"-u\"],\n1296 stdin=subprocess.PIPE,\n1297 stdout=dst,\n1298 text=True,\n1299 env={**os.environ, \"LC_ALL\": \"C\"},\n1300 )\n1301 for line in src:\n", + "col_offset": 24, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1294, + "line_range": [ + 1294, + 1295, + 1296, + 1297, + 1298, + 1299, + 1300 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1423 _maybe_append_username_flag(cmd)\n1424 result = subprocess.run(\n1425 cmd,\n1426 stdout=subprocess.PIPE,\n1427 stderr=subprocess.DEVNULL,\n1428 check=False,\n1429 )\n1430 with open(output_path, \"w\") as out:\n", + "col_offset": 13, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1424, + "line_range": [ + 1424, + 1425, + 1426, + 1427, + 1428, + 1429 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1581 _write_delimited_field(f\"{hcatHashFile}.out\", f\"{hcatHashFile}.working\", 2)\n1582 hcatProcess = subprocess.Popen(\n1583 [\n1584 sys.executable,\n1585 os.path.join(hate_path, \"PACK\", \"statsgen.py\"),\n1586 f\"{hcatHashFile}.working\",\n1587 \"-o\",\n1588 f\"{hcatHashFile}.masks\",\n1589 ]\n1590 )\n1591 try:\n", + "col_offset": 18, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1582, + "line_range": [ + 1582, + 1583, + 1584, + 1585, + 1586, + 1587, + 1588, + 1589, + 1590 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1596 \n1597 hcatProcess = subprocess.Popen(\n1598 [\n1599 sys.executable,\n1600 os.path.join(hate_path, \"PACK\", \"maskgen.py\"),\n1601 f\"{hcatHashFile}.masks\",\n1602 \"--targettime\",\n1603 str(hcatTargetTime),\n1604 \"--optindex\",\n1605 \"-q\",\n1606 \"--pps\",\n1607 \"14000000000\",\n1608 \"--minlength=7\",\n1609 \"-o\",\n1610 f\"{hcatHashFile}.hcmask\",\n1611 ]\n1612 )\n1613 try:\n", + "col_offset": 18, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1597, + "line_range": [ + 1597, + 1598, + 1599, + 1600, + 1601, + 1602, + 1603, + 1604, + 1605, + 1606, + 1607, + 1608, + 1609, + 1610, + 1611, + 1612 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1673 ):\n1674 expander_proc = subprocess.Popen(\n1675 [expander_path], stdin=src, stdout=subprocess.PIPE\n1676 )\n1677 expander_stdout = expander_proc.stdout\n", + "col_offset": 28, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1674, + "line_range": [ + 1674, + 1675, + 1676 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1679 raise RuntimeError(\"expander stdout pipe was not created\")\n1680 sort_proc = subprocess.Popen(\n1681 [\"sort\", \"-u\"],\n1682 stdin=expander_stdout,\n1683 stdout=dst,\n1684 env={**os.environ, \"LC_ALL\": \"C\"},\n1685 )\n1686 hcatProcess = sort_proc\n", + "col_offset": 24, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 1680, + "line_range": [ + 1680, + 1681, + 1682, + 1683, + 1684, + 1685 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "1679 raise RuntimeError(\"expander stdout pipe was not created\")\n1680 sort_proc = subprocess.Popen(\n1681 [\"sort\", \"-u\"],\n1682 stdin=expander_stdout,\n1683 stdout=dst,\n1684 env={**os.environ, \"LC_ALL\": \"C\"},\n1685 )\n1686 hcatProcess = sort_proc\n", + "col_offset": 24, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1680, + "line_range": [ + 1680, + 1681, + 1682, + 1683, + 1684, + 1685 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1814 _append_potfile_arg(hashcat_cmd)\n1815 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1816 assert generator_proc.stdout is not None\n", + "col_offset": 25, + "end_col_offset": 80, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1815, + "line_range": [ + 1815 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1815 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1816 assert generator_proc.stdout is not None\n1817 _run_hcat_cmd(\n", + "col_offset": 8, + "end_col_offset": 48, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.", + "line_number": 1816, + "line_range": [ + 1816 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b101_assert_used.html", + "test_id": "B101", + "test_name": "assert_used" + }, + { + "code": "1860 _append_potfile_arg(hashcat_cmd)\n1861 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1862 assert generator_proc.stdout is not None\n", + "col_offset": 25, + "end_col_offset": 80, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1861, + "line_range": [ + 1861 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1861 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1862 assert generator_proc.stdout is not None\n1863 _run_hcat_cmd(\n", + "col_offset": 8, + "end_col_offset": 48, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.", + "line_number": 1862, + "line_range": [ + 1862 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b101_assert_used.html", + "test_id": "B101", + "test_name": "assert_used" + }, + { + "code": "1895 _append_potfile_arg(hashcat_cmd)\n1896 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1897 assert generator_proc.stdout is not None\n", + "col_offset": 25, + "end_col_offset": 80, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 1896, + "line_range": [ + 1896 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "1896 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n1897 assert generator_proc.stdout is not None\n1898 _run_hcat_cmd(\n", + "col_offset": 8, + "end_col_offset": 48, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Use of assert detected. The enclosed code will be removed when compiling to optimised byte code.", + "line_number": 1897, + "line_range": [ + 1897 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b101_assert_used.html", + "test_id": "B101", + "test_name": "assert_used" + }, + { + "code": "1970 _yolo_wordlists = list_wordlist_files(hcatWordlists)\n1971 hcatLeft = random.choice(_yolo_wordlists)\n1972 hcatRight = random.choice(_yolo_wordlists)\n", + "col_offset": 23, + "end_col_offset": 53, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 330, + "link": "https://cwe.mitre.org/data/definitions/330.html" + }, + "issue_severity": "LOW", + "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.", + "line_number": 1971, + "line_range": [ + 1971 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b311-random", + "test_id": "B311", + "test_name": "blacklist" + }, + { + "code": "1971 hcatLeft = random.choice(_yolo_wordlists)\n1972 hcatRight = random.choice(_yolo_wordlists)\n1973 left_path = os.path.join(hcatWordlists, hcatLeft)\n", + "col_offset": 24, + "end_col_offset": 54, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 330, + "link": "https://cwe.mitre.org/data/definitions/330.html" + }, + "issue_severity": "LOW", + "issue_text": "Standard pseudo-random generators are not suitable for security/cryptographic purposes.", + "line_number": 1972, + "line_range": [ + 1972 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/blacklists/blacklist_calls.html#b311-random", + "test_id": "B311", + "test_name": "blacklist" + }, + { + "code": "2582 with _open_wordlist(source_file) as stdin_f:\n2583 hcatProcess = subprocess.Popen(\n2584 [hcstat2gen_bin, hcstat2_path], stdin=stdin_f, stderr=subprocess.PIPE\n2585 )\n2586 try:\n", + "col_offset": 26, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2583, + "line_range": [ + 2583, + 2584, + 2585 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2705 _append_potfile_arg(hashcat_cmd)\n2706 generator_proc = subprocess.Popen(generator_cmd, stdout=subprocess.PIPE)\n2707 try:\n", + "col_offset": 21, + "end_col_offset": 76, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2706, + "line_range": [ + 2706 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2760 with _open_wordlist(prince_base) as base:\n2761 prince_proc = subprocess.Popen(prince_cmd, stdin=base, stdout=subprocess.PIPE)\n2762 _run_hcat_cmd(\n", + "col_offset": 22, + "end_col_offset": 86, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2761, + "line_range": [ + 2761 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2800 _append_potfile_arg(hashcat_cmd)\n2801 pcfg_proc = subprocess.Popen(pcfg_cmd, stdout=subprocess.PIPE)\n2802 _run_hcat_cmd(\n", + "col_offset": 16, + "end_col_offset": 66, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2801, + "line_range": [ + 2801 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2858 try:\n2859 subprocess.run(cmd, check=True)\n2860 os.replace(tmp_path, cache_path)\n", + "col_offset": 12, + "end_col_offset": 43, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2859, + "line_range": [ + 2859 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2905 with _open_wordlist(wordlist) as wl_file:\n2906 permute_proc = subprocess.Popen(\n2907 [permute_path], stdin=wl_file, stdout=subprocess.PIPE\n2908 )\n2909 _run_hcat_cmd(\n", + "col_offset": 23, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2906, + "line_range": [ + 2906, + 2907, + 2908 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "2987 print(f\"[*] Running: {_format_cmd(cmd)}\")\n2988 proc = subprocess.Popen(cmd)\n2989 try:\n", + "col_offset": 11, + "end_col_offset": 32, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 2988, + "line_range": [ + 2988 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3042 _debug_cmd(hashcat_cmd)\n3043 enum_proc = subprocess.Popen(\n3044 enum_cmd, cwd=model_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE\n3045 )\n3046 try:\n", + "col_offset": 16, + "end_col_offset": 5, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 3043, + "line_range": [ + 3043, + 3044, + 3045 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3140 with open(f\"{hcatHashFile}.combined\", \"wb\") as combined_out:\n3141 combine_proc = subprocess.Popen(\n3142 [combine_path, f\"{hcatHashFile}.working\", f\"{hcatHashFile}.working\"],\n3143 stdout=subprocess.PIPE,\n3144 )\n3145 hcatProcess = subprocess.Popen(\n", + "col_offset": 23, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 3141, + "line_range": [ + 3141, + 3142, + 3143, + 3144 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3144 )\n3145 hcatProcess = subprocess.Popen(\n3146 [\"sort\", \"-u\"],\n3147 stdin=combine_proc.stdout,\n3148 stdout=combined_out,\n3149 env={**os.environ, \"LC_ALL\": \"C\"},\n3150 )\n3151 combine_proc.stdout.close()\n", + "col_offset": 22, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "Starting a process with a partial executable path", + "line_number": 3145, + "line_range": [ + 3145, + 3146, + 3147, + 3148, + 3149, + 3150 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b607_start_process_with_partial_path.html", + "test_id": "B607", + "test_name": "start_process_with_partial_path" + }, + { + "code": "3144 )\n3145 hcatProcess = subprocess.Popen(\n3146 [\"sort\", \"-u\"],\n3147 stdin=combine_proc.stdout,\n3148 stdout=combined_out,\n3149 env={**os.environ, \"LC_ALL\": \"C\"},\n3150 )\n3151 combine_proc.stdout.close()\n", + "col_offset": 22, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 3145, + "line_range": [ + 3145, + 3146, + 3147, + 3148, + 3149, + 3150 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3235 try:\n3236 result = subprocess.run(\n3237 [generate_rules_path, str(rule_count)],\n3238 capture_output=True,\n3239 text=True,\n3240 check=True,\n3241 )\n3242 with open(rules_path, \"w\") as f:\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 3236, + "line_range": [ + 3236, + 3237, + 3238, + 3239, + 3240, + 3241 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "3451 print(f\"Found session file: {session_file}\")\n3452 except Exception:\n3453 pass\n3454 \n", + "col_offset": 16, + "end_col_offset": 24, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Pass detected.", + "line_number": 3452, + "line_range": [ + 3452, + 3453 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b110_try_except_pass.html", + "test_id": "B110", + "test_name": "try_except_pass" + }, + { + "code": "4220 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4221 result = subprocess.run(\n4222 [len_bin, str(min_len), str(max_len)], stdin=fin, stdout=fout\n4223 )\n4224 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4221, + "line_range": [ + 4221, + 4222, + 4223 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4230 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4231 result = subprocess.run([req_bin, str(mask)], stdin=fin, stdout=fout)\n4232 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 77, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4231, + "line_range": [ + 4231 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4238 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4239 result = subprocess.run([req_bin, str(mask)], stdin=fin, stdout=fout)\n4240 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 77, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4239, + "line_range": [ + 4239 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4249 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4250 result = subprocess.run(cmd, stdin=fin, stdout=fout)\n4251 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 60, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4250, + "line_range": [ + 4250 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4257 with open(infile, \"rb\") as fin:\n4258 result = subprocess.run([splitlen_bin, outdir], stdin=fin)\n4259 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 66, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4258, + "line_range": [ + 4258 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4264 rli_bin = os.path.join(hate_path, \"hashcat-utils/bin/rli.bin\")\n4265 result = subprocess.run([rli_bin, infile, outfile, *remove_files])\n4266 return result.returncode == 0\n", + "col_offset": 13, + "end_col_offset": 70, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4265, + "line_range": [ + 4265 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4272 with open(outfile, \"wb\") as fout:\n4273 result = subprocess.run([rli2_bin, infile, remove_file], stdout=fout)\n4274 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 77, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4273, + "line_range": [ + 4273 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4280 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4281 result = subprocess.run(\n4282 [gate_bin, str(mod), str(offset)], stdin=fin, stdout=fout\n4283 )\n4284 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 9, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4281, + "line_range": [ + 4281, + 4282, + 4283 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4335 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4336 result = subprocess.run([cleanup_path, str(mode)], stdin=fin, stdout=fout)\n4337 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 82, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4336, + "line_range": [ + 4336 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4345 with open(infile, \"rb\") as fin, open(outfile, \"wb\") as fout:\n4346 result = subprocess.run([optimize_path], stdin=fin, stdout=fout)\n4347 return result.returncode == 0\n", + "col_offset": 17, + "end_col_offset": 72, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "LOW", + "issue_text": "subprocess call - check for execution of untrusted input.", + "line_number": 4346, + "line_range": [ + 4346 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b603_subprocess_without_shell_equals_true.html", + "test_id": "B603", + "test_name": "subprocess_without_shell_equals_true" + }, + { + "code": "4447 ),\n4448 shell=True,\n4449 )\n4450 try:\n4451 pipalProcess.wait()\n4452 except KeyboardInterrupt:\n4453 print(\"Killing PID {0}...\".format(str(pipalProcess.pid)))\n4454 pipalProcess.kill()\n4455 print(\"Pipal file is at \" + hcatHashFilePipal + \".pipal\\n\")\n4456 import sys\n4457 \n", + "col_offset": 27, + "end_col_offset": 13, + "filename": "hate_crack/main.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 78, + "link": "https://cwe.mitre.org/data/definitions/78.html" + }, + "issue_severity": "HIGH", + "issue_text": "subprocess call with shell=True identified, security issue.", + "line_number": 4448, + "line_range": [ + 4441, + 4442, + 4443, + 4444, + 4445, + 4446, + 4447, + 4448, + 4449 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b602_subprocess_popen_with_shell_equals_true.html", + "test_id": "B602", + "test_name": "subprocess_popen_with_shell_equals_true" + }, + { + "code": "186 line = line_bytes.decode(\"utf-8\", errors=\"replace\")\n187 except Exception:\n188 continue\n189 label = self._extract_username(line) or self.attack_name\n", + "col_offset": 12, + "end_col_offset": 24, + "filename": "hate_crack/notify/tailer.py", + "issue_confidence": "HIGH", + "issue_cwe": { + "id": 703, + "link": "https://cwe.mitre.org/data/definitions/703.html" + }, + "issue_severity": "LOW", + "issue_text": "Try, Except, Continue detected.", + "line_number": 187, + "line_range": [ + 187, + 188 + ], + "more_info": "https://bandit.readthedocs.io/en/1.9.4/plugins/b112_try_except_continue.html", + "test_id": "B112", + "test_name": "try_except_continue" + } + ] +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddd9cfa..91178d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,9 +33,12 @@ jobs: - name: Install dependencies run: uv sync --dev - - name: Ruff + - name: Ruff (lint) run: uv run ruff check hate_crack + - name: Ruff (format) + run: uv run ruff format --check hate_crack + - name: Ty run: uv run ty check hate_crack @@ -43,3 +46,48 @@ jobs: env: HATE_CRACK_SKIP_INIT: "1" run: uv run pytest -q + + # Bandit SAST against the committed baseline (.bandit-baseline.json): only + # findings NOT already in the baseline fail the build. hate_crack shells out + # to hashcat extensively, so the baseline captures the reviewed low-severity + # subprocess/shlex findings. Config lives in [tool.bandit] in pyproject.toml. + bandit: + name: Bandit SAST + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + + - name: Bandit (vs baseline) + run: >- + uvx --from "bandit[toml]==1.9.4" bandit + -r hate_crack -c pyproject.toml -b .bandit-baseline.json + + # pip-audit: fail on dependencies with known CVEs (PyPI / OSV advisory DB). + # Audits the synced project environment. PYSEC-2026-2447 (diskcache 5.6.3, a + # transitive dep via instructor -> atomic-agents) is ignored because no fixed + # release exists upstream; revisit when diskcache ships a fix. + pip-audit: + name: pip-audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + python-version: "3.13" + enable-cache: true + cache-dependency-glob: "**/uv.lock" + + - name: Install dependencies + run: uv sync + + - name: Audit dependencies + run: >- + uv run --with pip-audit==2.10.0 pip-audit + --progress-spinner off --ignore-vuln PYSEC-2026-2447 diff --git a/prek.toml b/prek.toml index f529529..1b2a642 100644 --- a/prek.toml +++ b/prek.toml @@ -10,6 +10,15 @@ stages = ["pre-push"] pass_filenames = false always_run = true +[[repos.hooks]] +id = "ruff-format" +name = "ruff-format" +entry = "uv run ruff format --check hate_crack" +language = "system" +stages = ["pre-push"] +pass_filenames = false +always_run = true + [[repos.hooks]] id = "ty" name = "ty" @@ -37,6 +46,15 @@ stages = ["pre-push"] pass_filenames = false always_run = true +[[repos.hooks]] +id = "bandit" +name = "bandit security scan (vs baseline)" +entry = "uvx --from 'bandit[toml]==1.9.4' bandit -r hate_crack -c pyproject.toml -b .bandit-baseline.json -q" +language = "system" +stages = ["pre-push"] +pass_filenames = false +always_run = true + [[repos.hooks]] id = "audit-docs" name = "audit-docs" @@ -45,3 +63,26 @@ language = "system" stages = ["post-commit"] pass_filenames = false always_run = true + +# General hygiene hooks (mirrors hashview's .pre-commit-config.yaml). These run +# at the pre-commit stage, so `prek install` must include `--hook-type pre-commit` +# (see CLAUDE.md). Auto-fixers modify files in place; re-stage and commit again. +[[repos]] +repo = "https://github.com/pre-commit/pre-commit-hooks" +rev = "v5.0.0" + +[[repos.hooks]] +id = "trailing-whitespace" + +[[repos.hooks]] +id = "end-of-file-fixer" + +[[repos.hooks]] +id = "check-yaml" + +[[repos.hooks]] +id = "check-merge-conflict" + +[[repos.hooks]] +id = "check-added-large-files" +args = ["--maxkb=1024"] diff --git a/pyproject.toml b/pyproject.toml index 2d0a432..0905fe7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,6 +50,23 @@ exclude = [ "rules", ] +[tool.bandit] +# hate_crack shells out to hashcat and its helper binaries extensively, so the +# scan produces many low-severity subprocess/shlex findings. These are reviewed +# and captured in .bandit-baseline.json; CI compares against that baseline so +# only NEW findings fail the build (mirrors the hashview approach). Scan the +# first-party package only — bundled third-party trees are excluded. +exclude_dirs = [ + "tests", + ".venv", + "build", + "dist", + "PACK", + "hashcat-utils", + "omen", + "princeprocessor", +] + [tool.ty.src] exclude = [ "build/", From a7233c2f4f3833df4cf659a3061ffa187b0db8bc Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Jul 2026 16:16:06 -0400 Subject: [PATCH 45/51] fix: tab completion on custom file-path prompts The "p. Enter a custom path" branches of the OMEN and Markov training pickers, the combipow wordlist prompt, and the rule cleanup/optimize output-path prompts used a bare input() with no readline completer, so TAB did nothing. Route them through select_file_with_autocomplete. Also drop the path completer after each selection so later numeric-menu and y/n prompts don't inherit stale file-path tab completion. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 14 +++++ hate_crack/attacks.py | 42 +++++++++++--- hate_crack/main.py | 7 ++- tests/test_combipow_attack.py | 31 +++++++--- tests/test_custom_path_autocomplete.py | 80 ++++++++++++++++++++++++++ tests/test_omen_attack.py | 4 +- tests/test_rule_tools.py | 42 +++++++++----- 7 files changed, 188 insertions(+), 32 deletions(-) create mode 100644 tests/test_custom_path_autocomplete.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c8f972..faa252e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.1] - 2026-07-25 + +### Fixed + +- **Tab completion on custom file-path prompts.** The `p. Enter a custom path` + branches of the OMEN and Markov training pickers, the combipow wordlist + prompt, and the rule cleanup/optimize output-path prompts used a bare + `input()` with no readline completer, so TAB did nothing. They now route + through `select_file_with_autocomplete` for consistent path autocompletion. +- **Stale completer leak.** `select_file_with_autocomplete` and the + `_configure_readline`-based pickers now drop the path completer after a + selection, so later numeric-menu and y/n prompts no longer inherit file-path + tab completion. + ## [2.14.0] - 2026-07-24 ### Added diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index f1e704c..89ec117 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -183,6 +183,7 @@ def quick_crack(ctx: Any) -> None: print("Please enter a valid wordlist or wordlist directory.") except ValueError: print("Please enter a valid number.") + readline.set_completer(None) selected_rules = _select_rules(ctx) if selected_rules is None: @@ -488,6 +489,7 @@ def _prompt_wordlist_paths(ctx, max_count: int) -> list[str]: count += 1 else: print(f"Not found: {resolved}") + readline.set_completer(None) return collected @@ -620,8 +622,10 @@ def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): if sel.lower() == "q": return None if sel.lower() == "p": - path = input("\n\tPath to wordlist: ").strip() - return path if path else None + path = ctx.select_file_with_autocomplete( + "\tPath to wordlist (tab to autocomplete)" + ) + return path.strip() if path else None try: idx = int(sel) if 1 <= idx <= len(wordlist_files): @@ -717,8 +721,10 @@ def _markov_pick_training_source(ctx: Any): if sel == "0" and has_cracked: return out_path if sel.lower() == "p": - path = input("\n\tPath to training file: ").strip() - return path if path else None + path = ctx.select_file_with_autocomplete( + "\tPath to training file (tab to autocomplete)" + ) + return path.strip() if path else None try: idx = int(sel) if 1 <= idx <= len(wordlist_files): @@ -795,7 +801,10 @@ def combipow_crack(ctx: Any) -> None: _notify.prompt_notify_for_attack("Combipow") wordlist = None while wordlist is None: - path = input("\nEnter path to wordlist (max 63 lines recommended): ").strip() + path = ctx.select_file_with_autocomplete( + "Enter path to wordlist (max 63 lines recommended, tab to autocomplete)" + ) + path = path.strip() if path else "" if not path: continue if not os.path.isfile(path): @@ -889,9 +898,11 @@ def generate_rules_crack(ctx: Any) -> None: wordlist_choice = raw_choice else: print("[!] Wordlist not found. Please enter a valid path.") + readline.set_completer(None) return except ValueError: print("Please enter a valid number.") + readline.set_completer(None) ctx.hcatGenerateRules(ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice) @@ -967,6 +978,7 @@ def permute_crack(ctx: Any) -> None: print("[!] A directory was provided. Please enter a single wordlist file.") continue wordlist_path = raw + readline.set_completer(None) ctx.hcatPermute(ctx.hcatHashType, ctx.hcatHashFile, wordlist_path) @@ -1015,7 +1027,10 @@ def _rule_select_file(ctx: Any, prompt: str = "Rule file: ") -> str: return None _configure_readline(rule_completer) - return input(prompt).strip() + try: + return input(prompt).strip() + finally: + readline.set_completer(None) def rule_cleanup_handler(ctx: Any) -> None: @@ -1026,7 +1041,10 @@ def rule_cleanup_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -1044,7 +1062,10 @@ def rule_optimize_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return @@ -1064,7 +1085,10 @@ def rule_cleanup_and_optimize_handler(ctx: Any) -> None: if not infile or not os.path.isfile(infile): print(f"[!] File not found: {infile}") return - outfile = input("Output file path: ").strip() + outfile = ctx.select_file_with_autocomplete( + "Output file path (tab to autocomplete)" + ) + outfile = outfile.strip() if outfile else "" if not outfile: print("[!] Output path required.") return diff --git a/hate_crack/main.py b/hate_crack/main.py index 6018b51..5380ba9 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -1230,7 +1230,12 @@ def select_file_with_autocomplete( full_prompt += f" (default: {default})" full_prompt += ": " - result = input(full_prompt).strip() + try: + result = input(full_prompt).strip() + finally: + # Drop the path completer so later plain prompts (numeric menus, y/n) + # don't inherit stale file-path tab completion. + readline.set_completer(None) if not result and base_dir: result = base_dir diff --git a/tests/test_combipow_attack.py b/tests/test_combipow_attack.py index 7d6f0eb..8f30e16 100644 --- a/tests/test_combipow_attack.py +++ b/tests/test_combipow_attack.py @@ -66,7 +66,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("correct\nhorse\nbattery\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() call_args = ctx.hcatCombipow.call_args @@ -75,12 +76,23 @@ class TestCombipowCrack: ) assert use_space is True + def test_wordlist_path_uses_autocomplete_not_input(self, tmp_path): + attacks = _load_attacks() + ctx = _make_ctx() + wl = tmp_path / "words.txt" + wl.write_text("correct\nhorse\n") + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): + attacks.combipow_crack(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + def test_calls_hcatCombipow_without_space_sep(self, tmp_path): attacks = _load_attacks() ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("correct\nhorse\nbattery\n") - with patch("builtins.input", side_effect=[str(wl), "n"]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=["n"]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() call_args = ctx.hcatCombipow.call_args @@ -94,7 +106,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(64)) + "\n") - with patch("builtins.input", return_value=str(wl)): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_not_called() @@ -103,7 +116,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(63)) + "\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() @@ -112,7 +126,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("test\n") - with patch("builtins.input", side_effect=["/nonexistent.txt", str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = ["/nonexistent.txt", str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() @@ -121,7 +136,8 @@ class TestCombipowCrack: ctx = _make_ctx(hash_type="3200", hash_file="/tmp/bcrypt.txt") wl = tmp_path / "words.txt" wl.write_text("word1\nword2\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) ctx.hcatCombipow.assert_called_once() args = ctx.hcatCombipow.call_args[0] @@ -134,7 +150,8 @@ class TestCombipowCrack: ctx = _make_ctx() wl = tmp_path / "words.txt" wl.write_text("\n".join(f"word{i}" for i in range(31)) + "\n") - with patch("builtins.input", side_effect=[str(wl), ""]): + ctx.select_file_with_autocomplete.side_effect = [str(wl)] + with patch("builtins.input", side_effect=[""]): attacks.combipow_crack(ctx) captured = capsys.readouterr() assert "large" in captured.out.lower() or "warning" in captured.out.lower() diff --git a/tests/test_custom_path_autocomplete.py b/tests/test_custom_path_autocomplete.py new file mode 100644 index 0000000..26e5c92 --- /dev/null +++ b/tests/test_custom_path_autocomplete.py @@ -0,0 +1,80 @@ +"""Regression tests: custom-path prompts must offer tab autocomplete. + +Previously the "p. Enter a custom path" branches (OMEN, Markov) and the +combipow wordlist prompt used a bare ``input()`` with no readline completer, +so TAB did nothing. They now route through ``select_file_with_autocomplete``. +Also covers the canonical selector dropping its completer afterward so later +plain prompts don't inherit stale file-path completion. +""" + +import os +import readline +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +os.environ["HATE_CRACK_SKIP_INIT"] = "1" + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _load_attacks(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.attacks as attacks # noqa: PLC0415 + + return attacks + + +def _make_ctx(): + ctx = MagicMock() + ctx.hcatHashType = "1000" + ctx.hcatHashFile = "/tmp/hashes.txt" + ctx.hcatWordlists = "/tmp/wordlists" + ctx.list_wordlist_files.return_value = [] + return ctx + + +class TestOmenCustomPath: + def test_custom_path_uses_autocomplete(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "/data/custom.txt" + with patch("builtins.input", side_effect=["p"]): + result = attacks._omen_pick_training_wordlist(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + assert result == "/data/custom.txt" + + def test_custom_path_blank_returns_none(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = None + with patch("builtins.input", side_effect=["p"]): + result = attacks._omen_pick_training_wordlist(ctx) + assert result is None + + +class TestMarkovCustomPath: + def test_custom_path_uses_autocomplete(self): + attacks = _load_attacks() + ctx = _make_ctx() + ctx.select_file_with_autocomplete.return_value = "/data/train.txt" + # out file absent -> "0" option not offered + with patch("builtins.input", side_effect=["p"]): + result = attacks._markov_pick_training_source(ctx) + ctx.select_file_with_autocomplete.assert_called_once() + assert result == "/data/train.txt" + + +class TestSelectorResetsCompleter: + def test_completer_reset_after_selection(self): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + import hate_crack.main as m # noqa: PLC0415 + + sentinel = object() + readline.set_completer(lambda t, s: None) + with patch("builtins.input", return_value="/some/path"): + m.select_file_with_autocomplete("Pick a file") + assert readline.get_completer() is None + del sentinel diff --git a/tests/test_omen_attack.py b/tests/test_omen_attack.py index cc61f9e..7239df1 100644 --- a/tests/test_omen_attack.py +++ b/tests/test_omen_attack.py @@ -355,12 +355,14 @@ class TestOmenAttackHandler: def test_custom_path_for_training(self, tmp_path): ctx = self._make_ctx(tmp_path, model_valid=False) self._setup_rules_dir(tmp_path) + ctx.select_file_with_autocomplete.return_value = "/custom/wordlist.txt" with patch("os.path.isfile", return_value=True), patch( - "builtins.input", side_effect=["p", "/custom/wordlist.txt", "", "0"] + "builtins.input", side_effect=["p", "", "0"] ): from hate_crack.attacks import omen_attack omen_attack(ctx) + ctx.select_file_with_autocomplete.assert_called_once() ctx.hcatOmenTrain.assert_called_once_with("/custom/wordlist.txt") def test_rules_passed_to_hcatOmen(self, tmp_path): diff --git a/tests/test_rule_tools.py b/tests/test_rule_tools.py index 051f01b..19b5b90 100644 --- a/tests/test_rule_tools.py +++ b/tests/test_rule_tools.py @@ -26,7 +26,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) ctx.rules_cleanup.assert_called_once_with(str(infile), str(outfile)) @@ -40,7 +41,8 @@ class TestRuleCleanupHandler: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) ctx.rules_cleanup.assert_not_called() @@ -49,7 +51,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) assert "[+] Done." in capsys.readouterr().out @@ -59,7 +62,8 @@ class TestRuleCleanupHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "clean.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_handler(ctx) assert "[!] Cleanup failed." in capsys.readouterr().out @@ -70,7 +74,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) ctx.rules_optimize.assert_called_once_with(str(infile), str(outfile)) @@ -84,7 +89,8 @@ class TestRuleOptimizeHandler: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) ctx.rules_optimize.assert_not_called() @@ -93,7 +99,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) assert "[+] Done." in capsys.readouterr().out @@ -103,7 +110,8 @@ class TestRuleOptimizeHandler: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "optimized.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_optimize_handler(ctx) assert "[!] Optimize failed." in capsys.readouterr().out @@ -114,7 +122,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\nu\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_cleanup.assert_called_once() ctx.rules_optimize.assert_called_once() @@ -125,7 +134,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "out.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_optimize.assert_not_called() @@ -139,7 +149,8 @@ class TestRuleCleanupAndOptimize: ctx = _make_ctx() infile = tmp_path / "test.rule" infile.write_text("l\n") - with patch("builtins.input", side_effect=[str(infile), ""]): + ctx.select_file_with_autocomplete.return_value = "" + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) ctx.rules_cleanup.assert_not_called() @@ -155,7 +166,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) assert captured_tmp, "rules_cleanup should have been called" assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" @@ -172,7 +184,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "out.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) if captured_tmp: assert not os.path.exists(captured_tmp[0]), "temp file should be cleaned up" @@ -182,7 +195,8 @@ class TestRuleCleanupAndOptimize: infile = tmp_path / "test.rule" infile.write_text("l\n") outfile = tmp_path / "final.rule" - with patch("builtins.input", side_effect=[str(infile), str(outfile)]): + ctx.select_file_with_autocomplete.return_value = str(outfile) + with patch("builtins.input", side_effect=[str(infile)]): rule_cleanup_and_optimize_handler(ctx) assert "[+] Done." in capsys.readouterr().out From 7a607df9935b60108f62447d2a4ad0b4c5d16174 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Jul 2026 16:19:47 -0400 Subject: [PATCH 46/51] refactor: rename _omen_pick_training_wordlist to _pick_training_wordlist The helper is shared by the OMEN attack and the LLM wordlist-mode attack, so the OMEN-specific name was misleading. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 8 ++++++++ hate_crack/attacks.py | 8 ++++---- tests/test_attacks_behavior.py | 14 +++++++------- tests/test_custom_path_autocomplete.py | 4 ++-- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index faa252e..c6129dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.2] - 2026-07-25 + +### Changed + +- Renamed the internal `_omen_pick_training_wordlist` helper to + `_pick_training_wordlist`, since it is shared by the OMEN, Markov-adjacent, + and LLM (wordlist mode) attacks rather than being OMEN-specific. + ## [2.14.1] - 2026-07-25 ### Fixed diff --git a/hate_crack/attacks.py b/hate_crack/attacks.py index 9df54e6..837bbce 100644 --- a/hate_crack/attacks.py +++ b/hate_crack/attacks.py @@ -592,7 +592,7 @@ def ollama_attack(ctx: Any) -> None: ) return elif choice == "2": - path = _omen_pick_training_wordlist(ctx, title="LLM Sample Wordlists") + path = _pick_training_wordlist(ctx, title="LLM Sample Wordlists") if not path: return ctx.hcatOllama(ctx.hcatHashType, ctx.hcatHashFile, "wordlist", path) @@ -606,7 +606,7 @@ def ollama_attack(ctx: Any) -> None: print("\t[!] Invalid selection.") -def _omen_pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): +def _pick_training_wordlist(ctx: Any, title: str = "Training Wordlists"): """Show wordlist picker. Returns path or None (user cancelled with 'q').""" wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) # Print the grid once, outside the retry loop: a wordlists directory can @@ -682,7 +682,7 @@ def omen_attack(ctx: Any) -> None: print("\n\tNo valid OMEN model found. Training is required.") if need_training: - training_file = _omen_pick_training_wordlist(ctx) + training_file = _pick_training_wordlist(ctx) if not training_file: return if not ctx.hcatOmenTrain(training_file): @@ -709,7 +709,7 @@ def _markov_pick_training_source(ctx: Any): has_cracked = os.path.isfile(out_path) and os.path.getsize(out_path) > 0 wordlist_files = ctx.list_wordlist_files(ctx.hcatWordlists) - # Print the grid once, outside the retry loop — see _omen_pick_training_wordlist. + # Print the grid once, outside the retry loop — see _pick_training_wordlist. entries = [] if has_cracked: entries.append("0) Cracked passwords (current session)") diff --git a/tests/test_attacks_behavior.py b/tests/test_attacks_behavior.py index ae7186b..5436dba 100644 --- a/tests/test_attacks_behavior.py +++ b/tests/test_attacks_behavior.py @@ -532,7 +532,7 @@ class TestOllamaAttack: class TestOmenPickTrainingWordlistReprompt: - """_omen_pick_training_wordlist re-prompts on invalid input instead of aborting.""" + """_pick_training_wordlist re-prompts on invalid input instead of aborting.""" def _make_ctx(self, wordlist_files=None): ctx = MagicMock() @@ -542,29 +542,29 @@ class TestOmenPickTrainingWordlistReprompt: return ctx def test_invalid_input_reprompts_then_valid_pick(self) -> None: - from hate_crack.attacks import _omen_pick_training_wordlist + from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) # First input is invalid, second is valid with patch("builtins.input", side_effect=["bad", "1"]): - result = _omen_pick_training_wordlist(ctx) + result = _pick_training_wordlist(ctx) assert result is not None assert "rockyou.txt" in result def test_cancel_with_q_returns_none(self) -> None: - from hate_crack.attacks import _omen_pick_training_wordlist + from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) with patch("builtins.input", return_value="q"): - result = _omen_pick_training_wordlist(ctx) + result = _pick_training_wordlist(ctx) assert result is None def test_multiple_invalid_inputs_then_cancel(self) -> None: - from hate_crack.attacks import _omen_pick_training_wordlist + from hate_crack.attacks import _pick_training_wordlist ctx = self._make_ctx(["rockyou.txt"]) with patch("builtins.input", side_effect=["99", "abc", "q"]): - result = _omen_pick_training_wordlist(ctx) + result = _pick_training_wordlist(ctx) assert result is None diff --git a/tests/test_custom_path_autocomplete.py b/tests/test_custom_path_autocomplete.py index 26e5c92..271aad3 100644 --- a/tests/test_custom_path_autocomplete.py +++ b/tests/test_custom_path_autocomplete.py @@ -41,7 +41,7 @@ class TestOmenCustomPath: ctx = _make_ctx() ctx.select_file_with_autocomplete.return_value = "/data/custom.txt" with patch("builtins.input", side_effect=["p"]): - result = attacks._omen_pick_training_wordlist(ctx) + result = attacks._pick_training_wordlist(ctx) ctx.select_file_with_autocomplete.assert_called_once() assert result == "/data/custom.txt" @@ -50,7 +50,7 @@ class TestOmenCustomPath: ctx = _make_ctx() ctx.select_file_with_autocomplete.return_value = None with patch("builtins.input", side_effect=["p"]): - result = attacks._omen_pick_training_wordlist(ctx) + result = attacks._pick_training_wordlist(ctx) assert result is None From 9d089d9a21cbf1e3eaab329e395945045407ad29 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Jul 2026 16:28:52 -0400 Subject: [PATCH 47/51] fix: pipal base-word parsing and shell-safe invocation pipal() built one rigid regex requiring exactly pipal_count consecutive base-word lines, so any cracked set with fewer unique base words than pipal_count (default 10) matched nothing and returned []. Parse the "Top N base words" section line by line instead, collecting up to pipal_count words and stopping at the blank line that ends the section. Also replace the shell=True string-formatted Popen with list-form arguments so file paths containing shell metacharacters can't be interpreted as commands. Adds tests/test_pipal_e2e.py: hermetic end-to-end tests that drive the real pipal() through a fake pipal executable (parsing, $HEX decode, fewer-basewords regression, and injection safety). Co-Authored-By: Claude Opus 4.8 (1M context) --- hate_crack/main.py | 63 +++++++----- tests/test_pipal_e2e.py | 211 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 247 insertions(+), 27 deletions(-) create mode 100644 tests/test_pipal_e2e.py diff --git a/hate_crack/main.py b/hate_crack/main.py index c03b1a9..2ac9602 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4443,15 +4443,18 @@ def pipal(): pipalFile.write(clearTextPass) pipalFile.close() - pipalProcess = subprocess.Popen( - "{pipal_path} {pipal_file} -t {pipal_count} --output {pipal_out}".format( - pipal_path=pipalPath, - pipal_file=hcatHashFilePipal + ".passwords", - pipal_out=hcatHashFilePipal + ".pipal", - pipal_count=pipal_count, - ), - shell=True, - ) + # List-form Popen (no shell=True) so paths/filenames containing + # shell metacharacters can't be interpreted as commands. shlex.split + # on pipalPath still allows an interpreter prefix (e.g. "ruby + # /opt/pipal/pipal.rb") to be configured. + pipal_cmd = shlex.split(pipalPath) + [ + hcatHashFilePipal + ".passwords", + "-t", + str(pipal_count), + "--output", + hcatHashFilePipal + ".pipal", + ] + pipalProcess = subprocess.Popen(pipal_cmd) try: pipalProcess.wait() except KeyboardInterrupt: @@ -4474,25 +4477,31 @@ def pipal(): print(pipalfile.read()) print("\n--- Pipal Output End ---\n") with open(hcatHashFilePipal + ".pipal") as pipalfile: - pipal_content = pipalfile.readlines() - raw_pipal = "\n".join(pipal_content) - raw_pipal = re.sub("\n+", "\n", raw_pipal) - raw_regex = r"Top [0-9]+ base words\n" - for word in range(pipal_count): - raw_regex += r"(\S+).*\n" - basewords_re = re.compile(raw_regex) - results = re.search(basewords_re, raw_pipal) + pipal_content = pipalfile.read() + # Parse the "Top N base words" section line by line rather than + # with one rigid regex. The old approach required *exactly* + # pipal_count baseword lines, so any cracked set with fewer + # unique base words than pipal_count (the common case on small + # cracks) matched nothing and returned []. Collect up to + # pipal_count base words and stop at the end of the section. top_basewords = [] - if results: - if results.lastindex is not None: - for i in range(1, results.lastindex + 1): - if i is not None: - top_basewords.append(results.group(i)) - else: - pass - return top_basewords - else: - return [] + in_section = False + for line in pipal_content.splitlines(): + if re.match(r"\s*Top\s+[0-9]+\s+base words", line): + in_section = True + continue + if in_section: + if not line.strip(): + # blank line terminates the base words section + break + # Capture the base word (first token); tolerate both + # "word = 5 (5%)" and "word 5" separators. + match = re.match(r"\s*(\S+)", line) + if match: + top_basewords.append(match.group(1)) + if len(top_basewords) >= pipal_count: + break + return top_basewords else: print("No hashes were cracked :(") return [] diff --git a/tests/test_pipal_e2e.py b/tests/test_pipal_e2e.py new file mode 100644 index 0000000..9b3139f --- /dev/null +++ b/tests/test_pipal_e2e.py @@ -0,0 +1,211 @@ +"""End-to-end tests for ``hate_crack.main.pipal``. + +These are hermetic e2e tests: instead of requiring a real Ruby + pipal.rb +install, they drop a small fake ``pipal`` executable on disk that consumes the +same CLI (`` -t --output ``) and emits a pipal-shaped +report. ``pipal()`` is then driven through its real code path — writing the +``.passwords`` file (including ``$HEX[...]`` decoding), spawning the subprocess +via ``subprocess.Popen(..., shell=True)``, and parsing the ``Top N base words`` +section back out. + +A real-tool variant is gated behind ``HATE_CRACK_PIPAL_REAL=1`` + +``HATE_CRACK_PIPAL_PATH`` for anyone who wants to run against genuine pipal.rb. +""" + +import os +import stat +import sys +import textwrap +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] + + +def _get_main_module(): + if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + os.environ["HATE_CRACK_SKIP_INIT"] = "1" + import hate_crack.main as m # noqa: PLC0415 + + return m + + +# A fake pipal that mimics the real tool closely enough for the parser: +# base word = password lowercased with trailing non-alphabetic characters +# stripped, ranked by descending frequency, and only the top -t entries are +# emitted under the "Top N base words" header. +_FAKE_PIPAL = textwrap.dedent( + """\ + #!/usr/bin/env python3 + import re + import sys + + args = sys.argv[1:] + pw_file = args[0] + top = 10 + out = None + i = 1 + while i < len(args): + if args[i] in ("-t", "--top"): + top = int(args[i + 1]); i += 2 + elif args[i] in ("-o", "--output"): + out = args[i + 1]; i += 2 + elif args[i] == "--output": + out = args[i + 1]; i += 2 + else: + i += 1 + + counts = {} + with open(pw_file, encoding="utf-8", errors="replace") as fh: + for line in fh: + pw = line.rstrip("\\n") + if not pw: + continue + base = re.sub(r"[^a-zA-Z]+$", "", pw).lower() + if not base: + continue + counts[base] = counts.get(base, 0) + 1 + + ranked = sorted(counts.items(), key=lambda kv: (-kv[1], kv[0])) + total = sum(counts.values()) + + lines = [] + lines.append("Basic Results") + lines.append("") + lines.append("Total entries = %d" % total) + lines.append("") + lines.append("Top %d base words" % top) + for word, c in ranked[:top]: + pct = 100.0 * c / total if total else 0.0 + lines.append("%s = %d (%.2f%%)" % (word, c, pct)) + lines.append("") + + with open(out, "w", encoding="utf-8") as fh: + fh.write("\\n".join(lines) + "\\n") + """ +) + + +def _install_fake_pipal(tmp_path: Path) -> Path: + fake = tmp_path / "pipal" + fake.write_text(_FAKE_PIPAL) + fake.chmod(fake.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH) + return fake + + +def _run_pipal(monkeypatch, m, tmp_path, cracked_lines, pipal_count): + """Wire up main-module globals and run the real ``pipal()``.""" + fake = _install_fake_pipal(tmp_path) + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") + (tmp_path / "hashes.txt.out").write_text("".join(cracked_lines)) + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", pipal_count) + # hcatHashFile / hcatHashType only exist after main() runs; create them. + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) # not NTLM + return m.pipal(), hashfile + + +class TestPipalE2E: + def test_returns_top_basewords(self, monkeypatch, tmp_path, capsys): + m = _get_main_module() + cracked = [ + "hash1:password1\n", + "hash2:password2\n", + "hash3:Password!\n", + "hash4:summer2021\n", + "hash5:summer99\n", + "hash6:winter1\n", + ] + result, hashfile = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=3) + + assert result == ["password", "summer", "winter"] + # pipal report and intermediate passwords file were produced + assert (Path(str(hashfile) + ".pipal")).is_file() + assert (Path(str(hashfile) + ".passwords")).is_file() + + def test_passwords_file_decodes_hex(self, monkeypatch, tmp_path): + m = _get_main_module() + # 70617373776f7264 == "password" + cracked = [ + "hash1:$HEX[70617373776f7264]\n", + "hash2:hunter2\n", + ] + _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=2) + + pw_path = tmp_path / "hashes.txt.passwords" + contents = pw_path.read_text(encoding="utf-8").splitlines() + assert "password" in contents # $HEX decoded, not written literally + assert "hunter2" in contents + assert not any(line.startswith("$HEX[") for line in contents) + + def test_no_cracked_output_returns_empty(self, monkeypatch, tmp_path): + m = _get_main_module() + fake = _install_fake_pipal(tmp_path) + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") # note: no .out file created + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + assert m.pipal() == [] + + def test_missing_pipal_path_returns_none(self, monkeypatch, tmp_path): + m = _get_main_module() + hashfile = tmp_path / "hashes.txt" + hashfile.write_text("dummy\n") + (tmp_path / "hashes.txt.out").write_text("hash1:password1\n") + + monkeypatch.setattr(m, "pipalPath", str(tmp_path / "does-not-exist")) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + assert m.pipal() is None + + def test_handles_shell_metacharacters_in_path(self, monkeypatch, tmp_path): + """The subprocess call must not use a shell (no command injection). + + A hash-file path containing shell metacharacters would, under the old + ``shell=True`` string command, either break or execute the injected + fragment. With list-form Popen it is handled as a literal path. + """ + m = _get_main_module() + weird_dir = tmp_path / "a b; touch INJECTED" + weird_dir.mkdir() + cracked = ["hash1:password1\n", "hash2:summer2021\n"] + + fake = _install_fake_pipal(tmp_path) + hashfile = weird_dir / "hashes.txt" + hashfile.write_text("dummy\n") + (weird_dir / "hashes.txt.out").write_text("".join(cracked)) + + monkeypatch.setattr(m, "pipalPath", str(fake)) + monkeypatch.setattr(m, "pipal_count", 3) + monkeypatch.setattr(m, "hcatHashFile", str(hashfile), raising=False) + monkeypatch.setattr(m, "hcatHashType", "0", raising=False) + + result = m.pipal() + + assert result == ["password", "summer"] + # the injected `touch INJECTED` must NOT have run + assert not (tmp_path / "INJECTED").exists() + assert not Path("INJECTED").exists() + + def test_fewer_basewords_than_count(self, monkeypatch, tmp_path): + """Real-world case: fewer unique base words than ``pipal_count``. + + A small cracked set should still surface the base words it *does* + have, rather than silently returning nothing. + """ + m = _get_main_module() + cracked = [ + "hash1:password1\n", + "hash2:summer2021\n", + "hash3:winter1\n", + ] + # default pipal_count (10) is larger than the 3 available base words + result, _ = _run_pipal(monkeypatch, m, tmp_path, cracked, pipal_count=10) + assert result == ["password", "summer", "winter"] From deb99f17f85b79a9fd44d74b37c1ff1443066307 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Jul 2026 16:29:15 -0400 Subject: [PATCH 48/51] docs: fold pipal fix into v2.14.2 changelog entry --- CHANGELOG.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6129dc..e64b6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ Dates are omitted for releases predating this file; see the git tags for exact t ## [2.14.2] - 2026-07-25 +### Fixed + +- **Pipal base-word parsing.** `pipal()` built one rigid regex that required + *exactly* `pipal_count` consecutive base-word lines, so any cracked set with + fewer unique base words than `pipal_count` (default 10 — the common case on + small cracks) matched nothing and returned no base words. The `Top N base + words` section is now parsed line by line, returning up to `pipal_count` + words and stopping at the end of the section. +- **Shell-safe pipal invocation.** The pipal subprocess is now spawned with + list-form arguments instead of a `shell=True` formatted string, so hash-file + paths containing shell metacharacters can no longer be interpreted as + commands. + ### Changed - Renamed the internal `_omen_pick_training_wordlist` helper to From 7e9c0a40fd5fec54230827f7de2c53f9388be0a6 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Jul 2026 17:28:47 -0400 Subject: [PATCH 49/51] chore: stop publishing local agent tooling and instructions CLAUDE.md, the .claude/ directory, and the agent-generated planning docs under docs/plans/ and docs/superpowers/ are local development aids, not part of the shipped project. Untrack them and add them to .gitignore so they stay local. Drops the post-commit audit-docs hook from prek.toml, since the script it invoked (.claude/audit-docs.sh) is no longer part of the repo. Co-Authored-By: Claude Fable 5 --- .gitignore | 6 ++++++ prek.toml | 13 ++----------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/.gitignore b/.gitignore index 9efc18a..7fdf8f2 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,9 @@ research/ 4_char_all all_hashes.enabled some_histories + +# Local agent tooling and instructions - intentionally not published +CLAUDE.md +.claude/ +docs/plans/ +docs/superpowers/ diff --git a/prek.toml b/prek.toml index 1b2a642..8e163e8 100644 --- a/prek.toml +++ b/prek.toml @@ -55,18 +55,9 @@ stages = ["pre-push"] pass_filenames = false always_run = true -[[repos.hooks]] -id = "audit-docs" -name = "audit-docs" -entry = "bash .claude/audit-docs.sh HEAD" -language = "system" -stages = ["post-commit"] -pass_filenames = false -always_run = true - # General hygiene hooks (mirrors hashview's .pre-commit-config.yaml). These run -# at the pre-commit stage, so `prek install` must include `--hook-type pre-commit` -# (see CLAUDE.md). Auto-fixers modify files in place; re-stage and commit again. +# at the pre-commit stage, so `prek install` must include `--hook-type pre-commit`. +# Auto-fixers modify files in place; re-stage and commit again. [[repos]] repo = "https://github.com/pre-commit/pre-commit-hooks" rev = "v5.0.0" From d91dc0dc5985c865d19a831d567683595dc7a55d Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Sat, 25 Jul 2026 17:41:14 -0400 Subject: [PATCH 50/51] chore(ci): add detect-private-key pre-commit gate The repo had no secret-scanning gate of any kind: prek.toml carried no detect-private-key/detect-secrets hook, CI had none, and bandit only scans hate_crack/ so it never inspected config files, docs, or fixtures for committed key material. Adds the detect-private-key hook from the pre-commit-hooks remote repo at the pre-commit stage, and records v2.14.3 in the changelog alongside the removal of the published agent tooling. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 18 ++++++++++++++++++ prek.toml | 5 +++++ 2 files changed, 23 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e64b6b3..f244fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.3] - 2026-07-25 + +### Added + +- **Private-key commit gate.** `prek.toml` now runs the `detect-private-key` + hook at the pre-commit stage. The repo previously had no secret-scanning gate + of any kind — bandit only covers `hate_crack/`, so nothing inspected config + files, docs, or test fixtures for committed key material. + +### Removed + +- **Local agent tooling is no longer published.** `CLAUDE.md`, `.claude/`, + `docs/plans/`, and `docs/superpowers/` were development aids rather than part + of the shipped project. They are now gitignored and were removed from the + repository, including from its history. +- **`audit-docs` post-commit hook.** Dropped from `prek.toml` along with the + `.claude/audit-docs.sh` script it invoked. + ## [2.14.2] - 2026-07-25 ### Fixed diff --git a/prek.toml b/prek.toml index 8e163e8..9ce02d2 100644 --- a/prek.toml +++ b/prek.toml @@ -77,3 +77,8 @@ id = "check-merge-conflict" [[repos.hooks]] id = "check-added-large-files" args = ["--maxkb=1024"] + +# Blocks committing PEM/OpenSSH private keys. The repo had no secret-scanning +# gate at all before this; bandit only covers hate_crack/. +[[repos.hooks]] +id = "detect-private-key" From a73d48fe2f28ed2e5abd3c5f53562025660ed66c Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 27 Jul 2026 12:03:50 -0400 Subject: [PATCH 51/51] docs: sync README with the current menu and undocumented features The main-menu block still showed the pre-consolidation numbering (LLM at 15, OMEN at 16) and was missing Notifications, PCFG, and PRINCE-LING entirely. Verified the block now matches get_main_menu_items() exactly. Also documents features that shipped without README coverage: - N-gram, PCFG, and PRINCE-LING attack sections - pcfgRuleset / pcfgMaxCandidates / pcfgPrinceLingMaxCandidates config keys - pcfg_cracker in the submodule and asset lists - llm, menu, noninteractive, notify, username_detect, formatting, and progress modules in Project Structure - LLM attack: structured output, target research pre-fill, spinner, timeout Co-Authored-By: Claude Fable 5 --- README.md | 113 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 84 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 792fa5f..636349f 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ ``` - ___ ___ __ _________ __ + ___ ___ __ _________ __ / | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __ / ~ \__ \\ __\/ __ \ / \ \/\_ __ \__ \ _/ ___\| |/ / -\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < +\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ ``` @@ -27,7 +27,7 @@ Or download a pre-built binary from https://hashcat.net/hashcat/ and set `hcatPa ### 2. Download hate_crack -Clone with submodules (required for hashcat-utils, princeprocessor, and optionally omen): +Clone with submodules (required for hashcat-utils, princeprocessor, pcfg_cracker, and optionally omen): ```bash git clone --recurse-submodules https://github.com/trustedsec/hate_crack.git @@ -46,7 +46,7 @@ Then customize configuration in `config.json` if needed (wordlist paths, API key The easiest way is to run `make` (or `make install`), which auto-detects your OS and installs: - External dependencies (p7zip, transmission-daemon / transmission-remote) -- Builds submodules (hashcat-utils, princeprocessor, and optionally omen) +- Builds submodules (hashcat-utils, princeprocessor, pcfg_cracker, and optionally omen) - Python dependencies via uv and a CLI shim at `~/.local/bin/hate_crack` ```bash @@ -96,6 +96,12 @@ Core logic is now split into modules under `hate_crack/`: - `hate_crack/api.py`: Hashview, Weakpass, and Hashmob integrations (downloads/menus/helpers). - `hate_crack/attacks.py`: menu attack handlers. - `hate_crack/hashmob_wordlist.py`: Hashmob wordlist utilities (thin wrapper; calls into api.py). +- `hate_crack/llm.py`: structured (JSON) LLM candidate generation via Atomic Agents. +- `hate_crack/menu.py`: shared menu renderer, including optional arrow-key navigation. +- `hate_crack/noninteractive.py`: dispatcher for the scripted attack subcommands. +- `hate_crack/notify/`: notification package (Pushover backend, per-crack tailer). +- `hate_crack/username_detect.py`: detects `username:hash` input files to decide on hashcat's `--username`. +- `hate_crack/formatting.py`, `hate_crack/progress.py`: output formatting and progress display helpers. - `hate_crack/main.py`: main CLI implementation. The top-level `hate_crack.py` remains the main entry point and orchestrates these modules. @@ -143,7 +149,7 @@ Config is also searched in: - The repo root and package directory - `~/hate_crack`, `~/hate-crack`, or `~/.hate_crack` -**Note:** The `hcatPath` in `config.json` is for the hashcat binary location only (optional if hashcat is in PATH). Hate_crack assets (hashcat-utils, princeprocessor, omen) are loaded from the repository directory and bundled automatically by `make install`. +**Note:** The `hcatPath` in `config.json` is for the hashcat binary location only (optional if hashcat is in PATH). Hate_crack assets (hashcat-utils, princeprocessor, pcfg_cracker, omen) are loaded from the repository directory and bundled automatically by `make install`. ### Run as a script The script uses a `uv` shebang. Make it executable and run: @@ -511,22 +517,38 @@ The attack offers three generation modes: [!] The values in parentheses below are the local model's GUESSES, not verified OSINT. Press Enter to accept, or type your own value to override. - Industry (freight rail maintenance): - Location (Omaha, Nebraska): + Industry (freight rail maintenance): + Location (Omaha, Nebraska): ``` Press Enter to accept a suggestion or type over it. These values are the model's recollection, **not OSINT** — treat them as a starting point, not intelligence about the client. The lookup uses only the local Ollama server, so the client name never leaves the host; there are no web or third-party API calls. If the model does not recognize the organization (the common case for small clients), it returns nothing and you get plain blank prompts: ``` Company name: Acme Rail Services - Industry: - Location: + Industry: + Location: ``` A research failure — timeout, Ollama not running, empty answer — never blocks the attack; it just falls back to blank prompts. Set `ollamaAutoResearch` to `false` to skip research entirely. 2. **Wordlist** — derive basewords from a sample wordlist. 3. **Cracked passwords** — feed the plaintexts already recovered this session (`.out`) back to the model so it can infer the target organization's own password conventions (basewords, seasons, years, suffixes, leetspeak) and generate *new* candidates in the same style. This option is only listed once at least one hash has been cracked; the sample is capped by `ollamaMaxSampleLines` exactly like Wordlist mode. +#### PCFG Configuration + +The PCFG Attack (option 20) and PRINCE-LING Attack (option 21) use the `pcfg_cracker` submodule. Configure them in `config.json`: + +```json +{ + "pcfgRuleset": "DEFAULT", + "pcfgMaxCandidates": 50000000, + "pcfgPrinceLingMaxCandidates": 10000000 +} +``` + +- **`pcfgRuleset`** — Name of the trained grammar to use (default: `DEFAULT`), resolved to `pcfg_cracker/Rules//`. Train your own with pcfg_cracker's `trainer.py` and set this to the ruleset name. +- **`pcfgMaxCandidates`** — Maximum candidates `pcfg_guesser.py` emits for the PCFG attack (default: `50000000`). +- **`pcfgPrinceLingMaxCandidates`** — Maximum base words `prince_ling.py` writes into the cached PRINCE base wordlist (default: `10000000`). + ### Notifications (menu option 82) hate_crack can send Pushover push notifications when attacks complete and, @@ -606,10 +628,10 @@ $ hashcat --help |grep -i ntlm ``` $ ./hate_crack.py 1000 - ___ ___ __ _________ __ + ___ ___ __ _________ __ / | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __ / ~ \__ \\ __\/ __ \ / \ \/\_ __ \__ \ _/ ___\| |/ / -\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < +\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| < \___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \ \/ \/ \/_____/ \/ \/ \/ \/ Version 2.0 @@ -730,20 +752,22 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi (7) Hybrid Attack (8) Pathwell Top 100 Mask Brute Force Crack (9) PRINCE Attack - (13) Bandrel Methodology - (14) Loopback Attack - (15) LLM Attack - (16) OMEN Attack - (17) Ad-hoc Mask Attack - (18) Markov Brute Force Attack - (19) N-gram Attack - (20) Permutation Attack - (21) Random Rules Attack - (22) Combipow Passphrase Attack + (10) Bandrel Methodology + (11) Loopback Attack + (12) LLM Attack + (13) OMEN Attack + (14) Ad-hoc Mask Attack + (15) Markov Brute Force Attack + (16) N-gram Attack + (17) Permutation Attack + (18) Random Rules Attack + (19) Combipow Passphrase Attack + (20) PCFG Attack + (21) PRINCE-LING Attack (80) Wordlist Tools - (81) Rule File Tools + (82) Notifications (90) Download rules from Hashmob.net (91) Analyze Hashcat Rules @@ -758,6 +782,10 @@ All tests use mocked API calls, so they can run without connectivity to a Hashvi Select a task: ``` + +Option `94 — Hashview API` is only listed when `hashview_api_key` is set in `config.json`. + +The YOLO, Middle, and Thorough Combinator attacks were previously at keys 10-12. They now live in the Combinator Attacks submenu (option 6) along with Combinator3 and CombinatorX. ------------------------------------------------------------------- #### Quick Crack Runs a dictionary attack against wordlists in your `hcatOptimizedWordlists` directory (falls back to `hcatWordlists` if not configured) and optionally applies rules. Multiple rules can be selected by comma-separated list, and chains can be created with the '+' symbol. Pressing Enter at the wordlist prompt uses the configured optimized wordlists directory as the default. @@ -771,9 +799,9 @@ Which rule(s) would you like to run? (99) YOLO...run all of the rules Enter Comma separated list of rules you would like to run. To run rules chained use the + symbol. For example 1+1 will run best64.rule chained twice and 1,2 would run best64.rule and then d3ad0ne.rule sequentially. -Choose wisely: +Choose wisely: ``` - + @@ -790,7 +818,7 @@ Runs several attack methods provided by Martin Bos (formerly known as pure_hate) * Hybrid Attack * Extra - Just For Good Measure - Runs a dictionary attack using `rockyou.txt` with chained `combinator.rule` and `InsidePro-PasswordsPro.rule` rules - + #### Brute Force Attack Brute forces all characters with the choice of a minimum and maximum password length. @@ -872,10 +900,12 @@ Uses hashcat's loopback mode to feed cracked passwords from the current session #### LLM Attack Uses a local Ollama instance to generate password candidates for a capture-the-flag scenario. Prompts for the fake company name, industry, and location, then sends these details to the configured LLM model to produce likely password candidates using industry terms and company name permutations. The generated candidates are fed into a hashcat wordlist+rules attack. -* Requires a running Ollama instance (default: `http://localhost:11434`) -* Configurable model and context window via `config.json` (see Ollama Configuration below) -* Prompts for target company name, industry, and location +* Requires a running Ollama instance (default: `http://localhost:11434`, override with `OLLAMA_HOST`) with the model already pulled — hate_crack does not auto-pull +* Candidate generation uses structured (JSON) output via Atomic Agents, so pick a model with good schema adherence (default: `qwen2.5:32b`) +* Configurable model, context window, request timeout, and sample size via `config.json` (see Ollama Configuration below) +* Prompts for target company name, industry, and location. The industry and location prompts are pre-filled with the local model's guesses about the named organization (editable, and clearly labelled as guesses rather than verified OSINT); disable with `ollamaAutoResearch: false` * Alternatively derives basewords from a sample **wordlist**, or from the **cracked passwords** of the current session (`.out`) so the model mirrors the target organization's own password conventions and produces new candidates in that style (only offered once something has been cracked) +* A live spinner with an elapsed-seconds counter runs during generation, and requests are bounded by `ollamaTimeout` so a model stuck loading into VRAM reports a timeout instead of hanging #### OMEN Attack Uses the Ordered Markov ENumerator (OMEN) to train a statistical password model from a wordlist and generate password candidates. This attack learns patterns from known passwords and generates new candidates based on those patterns. @@ -918,6 +948,14 @@ Generates password candidates using Markov chain statistical models. Similar to * Markov table persists with hash file (filename.out.hcstat2) for fast subsequent runs * Faster than OMEN for general-purpose brute forcing +#### N-gram Attack +Generates n-gram candidates from a corpus file using `ngramX.bin` from hashcat-utils and pipes them into hashcat. + +* Prompts for a corpus file with tab completion, defaulting to the configured wordlist directory +* Prompts for an n-gram group size (default 3) +* Gzip-compressed corpus files are auto-detected and decompressed on the fly +* Useful when you have target-relevant prose (scraped site copy, leaked documents, internal wiki exports) rather than a password list + #### Permutation Attack Generates all character permutations of each word in a targeted wordlist and pipes them to hashcat via `permute.bin` from hashcat-utils. @@ -943,6 +981,23 @@ Generates all unique non-empty subset combinations from a short wordlist using ` * Aborts with a clear message if the wordlist exceeds 63 lines (hard limit) * Candidates are piped directly to hashcat stdin +#### PCFG Attack +Uses [pcfg_cracker](https://github.com/lakiw/pcfg_cracker) to generate candidates from a Probabilistic Context-Free Grammar, piping `pcfg_guesser.py` output directly into hashcat's stdin mode. A PCFG models password *structure* (baseword + digits + symbol, capitalization habits, keyboard walks) with learned probabilities, so candidates come out roughly in descending likelihood order. + +* Requires the `pcfg_cracker` submodule. Presence is checked at startup and reported non-fatally: if it is missing, the PCFG attacks are simply unavailable. Run `make` to fetch it. +* Uses the trained grammar named by `pcfgRuleset` in `config.json` (default `DEFAULT`), read from `pcfg_cracker/Rules//` +* Candidate count is capped by `pcfgMaxCandidates` (default 50,000,000) +* hate_crack does not wrap grammar training. To build a grammar from a target-specific password set, run pcfg_cracker's own `trainer.py` and point `pcfgRuleset` at the resulting ruleset name + +#### PRINCE-LING Attack +Uses pcfg_cracker's `prince_ling.py` to derive an optimized PRINCE base wordlist from a trained grammar, then hands it to the existing PRINCE attack. PRINCE-LING picks base words the grammar says are actually productive, so the PRINCE combination space is far less wasteful than pointing PRINCE at a generic wordlist. + +* Requires the `pcfg_cracker` submodule and a trained ruleset directory, same as the PCFG attack +* The generated wordlist is cached at `/pcfg_prince_ling_.txt` and reused across sessions +* Regenerates only when the ruleset directory is newer than the cached wordlist, so retraining a grammar invalidates the cache automatically +* Generation is written to a temporary file and atomically moved into place; a failed or interrupted run cleans up its partial file and leaves any existing cache intact +* Base wordlist size is capped by `pcfgPrinceLingMaxCandidates` (default 10,000,000) + #### Wordlist Tools (option 80) A submenu of wordlist preprocessing utilities using hashcat-utils binaries. All tools read from and write to files on disk. All file and directory path prompts support tab completion. @@ -997,7 +1052,7 @@ Interactive menu for downloading and managing wordlists from Weakpass.com via Bi * Download specific wordlists or entire collections * Automatic extraction of compressed archives * Progress tracking for torrent downloads - + ------------------------------------------------------------------- ### Version History