Merge latest main into ruff bump for fix pass

This commit is contained in:
Justin Bollinger
2026-07-27 13:57:47 -04:00
36 changed files with 6983 additions and 1139 deletions
File diff suppressed because it is too large Load Diff
+49 -1
View File
@@ -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
+1 -1
View File
@@ -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
+6
View File
@@ -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/
+1
View File
@@ -0,0 +1 @@
3.13
+154
View File
@@ -7,6 +7,160 @@ 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
- **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
`_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
- **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
- **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.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
- **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
`<hashfile>.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 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.
### 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
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
yourself with `ollama pull <model>`.
## [2.11.4] - 2026-07-24
### Added
+141 -30
View File
@@ -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:
@@ -159,6 +165,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
@@ -459,18 +489,65 @@ 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, context window, and request timeout in `config.json`:
```json
{
"ollamaModel": "mistral",
"ollamaNumCtx": 2048
"ollamaModel": "qwen2.5:32b",
"ollamaNumCtx": 2048,
"ollamaTimeout": 300
}
```
- **`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.
- **`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 (`<hashfile>.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/<name>/`. 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)
@@ -551,10 +628,10 @@ $ hashcat --help |grep -i ntlm
```
$ ./hate_crack.py <hash file> 1000
___ ___ __ _________ __
___ ___ __ _________ __
/ | \_____ _/ |_ ____ \_ ___ \____________ ____ | | __
/ ~ \__ \\ __\/ __ \ / \ \/\_ __ \__ \ _/ ___\| |/ /
\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| <
\ Y // __ \| | \ ___/ \ \____| | \// __ \\ \___| <
\___|_ /(____ /__| \___ >____\______ /|__| (____ /\___ >__|_ \
\/ \/ \/_____/ \/ \/ \/ \/
Version 2.0
@@ -675,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
@@ -703,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.
@@ -716,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:
```
@@ -735,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.
@@ -817,9 +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 (`<hashfile>.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.
@@ -862,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.
@@ -887,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/<name>/`
* 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 `<hcatOptimizedWordlists>/pcfg_prince_ling_<ruleset>.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.
@@ -941,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
+4 -1
View File
@@ -25,8 +25,11 @@
"hashview_url": "http://localhost:8443",
"hashview_api_key": "",
"hashmob_api_key": "",
"ollamaModel": "mistral",
"ollamaModel": "qwen2.5:32b",
"ollamaNumCtx": 2048,
"ollamaTimeout": 300,
"ollamaMaxSampleLines": 500,
"ollamaAutoResearch": true,
"omenTrainingList": "rockyou.txt",
"omenMaxCandidates": 50000000,
"pcfgRuleset": "DEFAULT",
+1 -3
View File
@@ -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__"]
+61 -33
View File
@@ -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)
+239 -94
View File
@@ -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
@@ -169,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)
@@ -182,6 +181,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:
@@ -249,9 +249,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.
@@ -307,7 +305,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 = (
@@ -317,7 +317,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.")
@@ -331,14 +333,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:
@@ -487,6 +493,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
@@ -510,45 +517,128 @@ 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")
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)
# 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
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()
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,
"target",
{"company": company, "industry": industry, "location": location},
)
return
elif choice == "2":
path = _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
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):
"""Show wordlist picker for OMEN training. Returns path or None."""
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
# 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(
"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()
if sel.lower() == "p":
path = input("\n\tPath to training 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
print("\tq. Cancel")
while True:
sel = input("\n\tSelect wordlist: ").strip()
if sel.lower() == "q":
return None
if sel.lower() == "p":
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):
return os.path.join(ctx.hcatWordlists, wordlist_files[idx - 1])
except (ValueError, IndexError):
pass
print("\t[!] Invalid selection.")
def omen_attack(ctx: Any) -> None:
@@ -570,21 +660,29 @@ 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.")
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):
@@ -606,11 +704,12 @@ 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)
# Print the grid once, outside the retry loop — see _pick_training_wordlist.
entries = []
if has_cracked:
entries.append("0) Cracked passwords (current session)")
@@ -624,20 +723,25 @@ def _markov_pick_training_source(ctx: Any):
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
print("\tq. Cancel")
while True:
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 = 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):
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:
@@ -707,13 +811,16 @@ 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 = 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):
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(
@@ -725,7 +832,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)
@@ -735,7 +842,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()
@@ -801,11 +910,15 @@ 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)
ctx.hcatGenerateRules(
ctx.hcatHashType, ctx.hcatHashFile, rule_count, wordlist_choice
)
def ngram_attack(ctx: Any) -> None:
@@ -825,7 +938,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:
@@ -841,7 +954,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)
@@ -867,9 +982,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):
@@ -879,11 +992,11 @@ 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)
def combinator_submenu(ctx: Any) -> None:
items = [
("1", "Combinator Attack (2-8 wordlists)"),
@@ -927,7 +1040,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:
@@ -938,7 +1054,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
@@ -956,7 +1075,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
@@ -976,7 +1098,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
@@ -1026,12 +1151,14 @@ 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
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:
@@ -1046,12 +1173,16 @@ 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)")
mask = int(input("[*] Enter mask value: ").strip() or "0")
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}")
else:
@@ -1066,12 +1197,14 @@ 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
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:
@@ -1086,12 +1219,14 @@ 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
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}")
@@ -1107,7 +1242,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
@@ -1123,7 +1260,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(
@@ -1138,7 +1275,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
@@ -1153,12 +1292,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:
@@ -1186,7 +1329,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
@@ -1232,7 +1375,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
+3 -1
View File
@@ -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)
+5
View File
@@ -22,6 +22,11 @@
"hashview_url": "http://localhost:8443",
"hashview_api_key": "",
"hashmob_api_key": "",
"ollamaModel": "qwen2.5:32b",
"ollamaNumCtx": 2048,
"ollamaTimeout": 300,
"ollamaMaxSampleLines": 500,
"ollamaAutoResearch": true,
"passgptModel": "javirandor/passgpt-10characters",
"passgptMaxCandidates": 1000000,
"passgptBatchSize": 1024,
+311
View File
@@ -0,0 +1,311 @@
"""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`` and ``research_target``.
"""
import instructor
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
# 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.
Raised instead of ``openai.APITimeoutError`` so callers do not need to import
``openai`` themselves.
"""
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."
),
)
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 "
"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.",
],
)
_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."""
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
)
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}")
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,
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 and LLMTimeoutError if the request
exceeds ``timeout``. Other client/connection errors propagate to the caller.
"""
request = _build_request(mode, context_data)
client = _build_client(url, timeout)
# _build_request has already rejected unknown modes, so this lookup is safe.
prompt_generator = _PROMPTS[mode]
agent = AtomicAgent[GenerationInput, PasswordCandidatesOutput](
config=AgentConfig(
client=client,
model=model,
system_prompt_generator=prompt_generator,
model_api_parameters={"extra_body": {"options": {"num_ctx": num_ctx}}},
)
)
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] = []
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
+286 -204
View File
@@ -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,9 @@ 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 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
@@ -361,6 +362,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,15 +468,20 @@ 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")
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))
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))
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"]
@@ -725,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.")
@@ -757,6 +786,7 @@ hcatGenerateRulesCount = 0
hcatPermuteCount = 0
hcatProcess: subprocess.Popen[Any] | None = None
debug_mode = False
non_interactive = False
hcatUsernamePrefix: bool = False
@@ -895,6 +925,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.
@@ -1191,7 +1238,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
@@ -2036,171 +2088,182 @@ 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"},
)
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:
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
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 pulling model: {e}")
return False
print(f"Successfully pulled model '{model}'.")
return True
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
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):
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):
print(f"Error: Wordlist not found: {wordlist_path}")
return
lines = []
try:
with open(wordlist_path, "r", errors="ignore") as f:
for line in f:
stripped = line.strip()
if not stripped:
continue
# Use only content after the first colon (e.g. hash:password -> password)
if ":" in stripped:
stripped = stripped.split(":", 1)[1]
if stripped:
lines.append(stripped)
except Exception as e:
print(f"Error reading wordlist: {e}")
sampled = _sample_plaintext_file(wordlist_path, ollamaMaxSampleLines)
if sampled is None:
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(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
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":
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
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')}")
# Step B: generate candidates via the Atomic Agents module.
try:
req = urllib.request.Request(
api_url,
data=payload,
headers={"Content-Type": "application/json"},
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(
f"The model ({ollamaModel}) may still be loading into VRAM. Retry, or "
'raise "ollamaTimeout" in config.json to wait longer.'
)
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.")
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.
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 +2278,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 +2304,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.")
@@ -2773,8 +2831,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"
@@ -3619,9 +3680,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:
@@ -3891,8 +3950,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 = []
@@ -4032,6 +4091,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:
@@ -4375,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:
@@ -4406,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 []
@@ -4703,6 +4780,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
@@ -4712,6 +4790,7 @@ def main():
# Initialize global variables
hcatHashFile = None
non_interactive = False
hcatHashType = None
hcatHashFileOrig = None
@@ -4798,6 +4877,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"
@@ -4924,7 +5004,8 @@ def main():
else:
argv = argv_temp # Fallback if subcommand not found
use_subcommand_parser = "hashview" 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,
include_subcommands=use_subcommand_parser,
@@ -4933,6 +5014,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):
@@ -5231,8 +5314,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"
@@ -5254,12 +5337,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()
@@ -5305,8 +5386,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"
@@ -5329,12 +5410,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
@@ -5385,6 +5464,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()
+145
View File
@@ -0,0 +1,145 @@
"""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
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.
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()
if not chain:
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.rule_files)
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
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=[],
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.",
)
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)",
)
+71
View File
@@ -0,0 +1,71 @@
"""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()
# 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()
+48 -27
View File
@@ -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(
+41 -4
View File
@@ -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"
@@ -38,10 +47,38 @@ pass_filenames = false
always_run = true
[[repos.hooks]]
id = "audit-docs"
name = "audit-docs"
entry = "bash .claude/audit-docs.sh HEAD"
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 = ["post-commit"]
stages = ["pre-push"]
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`.
# 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"]
# 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"
+29 -6
View File
@@ -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]
@@ -9,12 +9,18 @@ description = "Menu driven Python wrapper for hashcat"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"requests>=2.31.0",
"beautifulsoup4>=4.12.0",
"requests>=2.34.2",
"beautifulsoup4>=4.15.0",
"openpyxl>=3.0.0",
"packaging>=21.0",
"packaging>=26.2",
"simple-term-menu==1.6.6",
"click>=8.0.0",
"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]
@@ -44,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/",
@@ -75,6 +98,6 @@ dev = [
"ty==0.0.17",
"ruff==0.16.0",
"pytest==9.0.3",
"pytest-cov==7.0.0",
"pytest-cov==7.1.0",
"pytest-timeout>=2.4.0",
]
+253 -4
View File
@@ -329,7 +329,10 @@ class TestOllamaAttack:
def test_calls_hcatOllama_with_context(self) -> None:
ctx = _make_ctx()
with patch("builtins.input", side_effect=["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=["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=[" 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,247 @@ 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("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"
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" 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_escape_cancels_without_calling_hcatOllama(self) -> None:
"""None from interactive_menu (Escape / 99) cancels the attack."""
ctx = _make_ctx()
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" from interactive_menu, then user cancels the file picker
ctx.list_wordlist_files.return_value = []
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
) -> 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))
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)
# 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
) -> 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))
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)
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
) -> 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))
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)
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(
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("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:
"""_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 _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 = _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 _pick_training_wordlist
ctx = self._make_ctx(["rockyou.txt"])
with patch("builtins.input", return_value="q"):
result = _pick_training_wordlist(ctx)
assert result is None
def test_multiple_invalid_inputs_then_cancel(self) -> None:
from hate_crack.attacks import _pick_training_wordlist
ctx = self._make_ctx(["rockyou.txt"])
with patch("builtins.input", side_effect=["99", "abc", "q"]):
result = _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()
+24 -7
View File
@@ -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()
+80
View File
@@ -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._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._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
+316
View File
@@ -0,0 +1,316 @@
"""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_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:
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_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:
hc_main.hcatOllama("0", ollama_env.hash_file, "bogus", {})
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 <hashfile>.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"
+322
View File
@@ -0,0 +1,322 @@
"""Unit tests for hate_crack.llm.generate_candidates."""
import os
from unittest import mock
import httpx
import instructor
import openai
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.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_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_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(
[" 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=<AgentConfig>) — 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_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,
)
# ---------------------------------------------------------------------------
# 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"
)
+10
View File
@@ -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", ""), \
+16 -10
View File
@@ -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}")
+259
View File
@@ -0,0 +1,259 @@
import os
import sys
from types import SimpleNamespace
import pytest
import hate_crack.main as hc_main
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"])
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, 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, ["+"])
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), rule_files=["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"), rule_files=[])
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), rule_files=["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), {})
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), rule_files=["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')}",
]
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
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
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
+301
View File
@@ -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
+75
View File
@@ -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)
+461
View File
@@ -0,0 +1,461 @@
"""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(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
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# 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
# ---------------------------------------------------------------------------
# Invalid-cap guard — zero/negative ollamaMaxSampleLines 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
# ---------------------------------------------------------------------------
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
# ---------------------------------------------------------------------------
# _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:") == ""
# ---------------------------------------------------------------------------
# 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
+43 -15
View File
@@ -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
@@ -337,19 +355,23 @@ 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):
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 +383,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 +396,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 +409,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
+211
View File
@@ -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 (``<passwords> -t <N> --output <file>``) 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"]
+151
View File
@@ -0,0 +1,151 @@
"""Tests for hate_crack/progress.py — the generic spinner context manager."""
from __future__ import annotations
import io
import re
import sys
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(
monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
"""In a non-TTY environment the message is printed once and no thread runs."""
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 must never construct a Thread."""
buf = io.StringIO()
buf.isatty = lambda: False # type: ignore[attr-defined]
with mock.patch("sys.stdout", buf), mock.patch(
"hate_crack.progress.threading.Thread"
) as mock_thread:
with spinner("No threads please"):
pass
mock_thread.assert_not_called()
# ---------------------------------------------------------------------------
# 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..."):
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.
assert "\033[2K\r" in output or ("\033[2K" in output)
def test_tty_shows_elapsed_seconds() -> None:
"""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]
# 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..."):
pass # body exits immediately; the thread gets one tick then stops
output = buf.getvalue()
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:
"""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")
# ---------------------------------------------------------------------------
# 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"
)
-684
View File
@@ -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"
+28 -14
View File
@@ -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