From 3529bfdcc882ab186d3828f104b6d87e0cb8ee33 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 28 Sep 2026 19:16:32 -0400 Subject: [PATCH 1/2] feat(cli): non-interactive subcommands for 17 more menu attacks (#340) The scripted surface was `quick | dict | brute | topmask` -- three of the twenty-five menu attacks -- so anything driving hate_crack programmatically had to fall back to feeding keystrokes to the menu for the rest. Adds, needing nothing beyond the hash file and hash type: fingerprint, combinator, hybrid, pathwell, prince, pcfg, princeling, smartmask, corporate. Adds, taking the one input their menu entry prompts for: bandrel (--company), permute, adhocmask, ngram, combipow, spoonman, omen, loopback. Each also accepts the tuning its prompts offer, and an omitted flag passes the attack function's own default rather than restating it here. noninteractive.py is now driven by one ATTACK_SPECS table rather than parallel subparser registrations and an if-chain. ATTACK_COMMANDS is derived from it instead of hand-maintained, which closes a failure mode that grows with each subcommand: main.py sets `non_interactive` from membership in that tuple, so a name reaching the subparsers but not the tuple would run the attack with every interactive prompt still live, blocking on a stdin nothing is attached to. Three corrections to the issue's tables, confirmed against the source: - Menu 11 "Loopback" is not hcatRecycle. attacks.loopback_attack runs hcatQuickDictionary(loopback=True) over an empty wordlist; hcatRecycle's third argument is a newly-cracked count used as an internal gate by extensive_crack only. The subcommand therefore takes --rules. - hcatBandrel was the one attack that genuinely could not be scripted: a `while True: input()` with no default, which raises EOFError with no terminal. It now takes company_name=None and prompts only when unset. - Menu 6 "Combinator Attacks" is a submenu of four; this wires hcatCombination. Also fixes a latent bug the new flag would have made routine: "Acme, Globex" split to " Globex", whose first character is a space, and the per-baseword masks are built from name[0]/name[1:] -- so the second company produced `-1 ` and matched nothing. Entries are stripped and blanks dropped. Five entries stay interactive-only, as the issue scoped it: Markov brute force, Random Rules, Rosetta, the LLM attack, and Extensive Pure_Hate. Closes #340 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 8 + CLAUDE.md | 42 +- README.md | 88 +++ hate_crack/main.py | 23 +- hate_crack/noninteractive.py | 681 +++++++++++++++++++++-- tests/test_bandrel_company_arg.py | 92 ++++ tests/test_noninteractive_attacks.py | 790 +++++++++++++++++++++++++++ 7 files changed, 1659 insertions(+), 65 deletions(-) create mode 100644 tests/test_bandrel_company_arg.py create mode 100644 tests/test_noninteractive_attacks.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a7e065d3..fc7924a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,15 @@ Dates are omitted for releases predating this file; see the git tags for exact t ## [Unreleased] +### Added +- **Non-interactive subcommands for seventeen more menu attacks (#340).** The scripted surface was `quick | dict | brute | topmask` — three of the twenty-five menu attacks, so anything driving hate_crack programmatically (a scheduler, a CI harness, a tool choosing what to run next) had to fall back to feeding keystrokes to the menu for the rest. Added, taking nothing beyond the hash file and hash type: `fingerprint`, `combinator`, `hybrid`, `pathwell`, `prince`, `pcfg`, `princeling`, `smartmask`, `corporate`. Added, taking the one input their menu entry prompts for: `bandrel` (`--company`), `permute` (`--wordlist`), `adhocmask` (`--mask`), `ngram` (`--corpus`), `combipow` (`--wordlist`), `spoonman` (`--corpus`), `omen` (`--max-candidates`), `loopback` (`--rules`). Each also accepts the tuning its prompts offer — `fingerprint --max-expander-len/--keyspace-limit`, `corporate --min/--max`, and so on — and an omitted flag passes the attack function's own default rather than restating it. Five entries stay interactive-only because a flag cannot carry what they need: Markov brute force, Random Rules, Rosetta, the LLM attack, and Extensive Pure_Hate. `--exit-code-on-skip` works uniformly across all of them, and exit `2` still means "this build does not have that subcommand" rather than "it ran". +- **`hcatBandrel` accepts the company name as an argument.** It was the one attack in the group above that could not be scripted at all: the name came from a `while True: input(...)` loop that refused an empty answer and had no default, so a run with no terminal raised `EOFError` inside the loop rather than proceeding. Passing `company_name` skips the prompt; omitting it is unchanged. + ### Changed +- **`hate_crack/noninteractive.py` is now driven by one `ATTACK_SPECS` table** instead of parallel subparser registrations and an `if`-chain dispatcher. `ATTACK_COMMANDS` is derived from it rather than hand-maintained, which closes a failure mode that grows with each subcommand added: `main.py` sets its `non_interactive` global from membership in that tuple, so a name registered as a subparser but missed in the tuple would have run the attack with every interactive prompt still live, blocking on a stdin nothing is attached to. Tests assert the derivation in both directions. + +### Fixed +- **A comma-separated Bandrel company list no longer builds a mask from a leading space.** `"Acme, Globex"` split to `" Globex"`, whose first character is a space, and the per-baseword masks are derived from `name[0]` and `name[1:]` — so the second company produced `-1 ` and a mask matching nothing. Latent before now, since the prompt made multi-company input awkward; the new `--company` flag documents comma separation, which would have made it routine. Entries are stripped and blanks dropped. - **`test_commitizen_pin_is_still_coherent` no longer asserts *which* commitizen version is pinned**, only that exactly one exact `commitizen==X.Y.Z` pin exists. The literal it compared against meant every Dependabot bump of commitizen failed CI and needed a matching edit to the test before it could land (PR #322). Neither invariant the test documents -- that a pin exists so `cz commit` keeps working locally, and that neither tagging workflow calls `cz bump` -- depends on the version number, so the literal bought nothing. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 1a347651..89a4a104 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -630,10 +630,44 @@ notification prompt's `input()` call. Alongside the interactive menu, `main.py` exposes scripted entry points: -- **Scripted attacks** — `quick | dict | brute | topmask` subcommands - (`hate_crack/noninteractive.py`'s `ATTACK_COMMANDS`), with `--wordlist`, - `--rules` (supports `a+b` chaining and multi-token passes), `--min`/`--max`, - `--target-time`. +- **Scripted attacks** — 21 subcommands covering all but five of the menu + attacks (#340). Every one is a row in `hate_crack/noninteractive.py`'s + **`ATTACK_SPECS`**, which is the source of truth: `ATTACK_COMMANDS` is + derived from it, and both `add_attack_subparsers` and `_dispatch` iterate + it. **Add a subcommand by adding a row, never by editing those three + separately** — `main.py` sets its `non_interactive` global from membership + in `ATTACK_COMMANDS` (`main.py:9992`), so a name that reached the subparsers + but not the tuple would run the attack with every interactive prompt still + live, blocking on a stdin nothing is attached to. + `tests/test_noninteractive_attacks.py` pins the derivation in both + directions. + + Still interactive-only, as #340 scoped it: `hcatMarkovBruteForce`, + `hcatGenerateRules`, `hcatRosetta`, `hcatOllama`, and Extensive Pure_Hate + (an orchestrator, not a single attack). + + Three things the issue's own tables got wrong, confirmed against the source + and worth not rediscovering: + + - **Menu 11 "Loopback" is not `hcatRecycle`.** `attacks.loopback_attack` + runs `hcatQuickDictionary(..., loopback=True)` over an empty wordlist. + `hcatRecycle`'s third argument is a count of newly cracked passwords used + as an internal gate by `extensive_crack` only, and is meaningless as a CLI + argument — so the `loopback` subcommand takes `--rules`. + - **`hcatBandrel` was the one attack that genuinely could not be scripted**, + because of a blocking `while True: input()` with no default. It now takes + `company_name=None`; the prompt runs only when that is unset. + - **Menu 6 "Combinator Attacks" is a submenu of four** (combinator, YOLO, + middle, thorough). The `combinator` subcommand wires `hcatCombination` + only. + + Flags follow the kebab-case of the underlying `hcat*` parameter, and an + omitted flag passes the function's own default rather than restating it + here — `corporate` omits `minLen`/`maxLen` entirely when unset so + `hcatCorporateMasks` applies and clamps its own. The one place that + distinction is load-bearing is `fingerprint`'s `--dictionary-wordlist`: + `None` means "fall back to config" and `""` means "skip the step", so + `--no-dictionary-wordlist` exists to reach the second. - **`hashview` subparser tree** (`main.py:6439-6520`): `upload-cracked`, `upload-wordlist`, `download-left`, `download-rules`, `upload-hashfile-job`. - **Top-level flags** (`main.py:6297-6430`): `--download-hashview`, diff --git a/README.md b/README.md index dfa0892c..3333c3da 100644 --- a/README.md +++ b/README.md @@ -215,6 +215,94 @@ hate_crack brute hashes.txt 1000 --min 1 --max 8 hate_crack topmask hashes.txt 1000 --target-time 4 ``` +Most of the menu's attacks are available the same way. These need nothing +beyond the hash file and hash type: + +```bash +hate_crack fingerprint hashes.txt 1000 +hate_crack pathwell hashes.txt 1000 +hate_crack prince hashes.txt 1000 +hate_crack pcfg hashes.txt 1000 +hate_crack princeling hashes.txt 1000 +hate_crack smartmask hashes.txt 1000 +hate_crack corporate hashes.txt 1000 +hate_crack combinator hashes.txt 1000 # configured wordlists +hate_crack hybrid hashes.txt 1000 # configured wordlists +``` + +Each also accepts the settings its menu entry prompts for: + +```bash +# Fingerprint, tuned: escalate to 24-character fragments and cap the keyspace +hate_crack fingerprint hashes.txt 1000 --max-expander-len 24 \ + --run-hybrid-on-expanded --keyspace-limit 50000000000 + +# Skip the fragment/wordlist combination step entirely +hate_crack fingerprint hashes.txt 1000 --no-dictionary-wordlist + +# Override the configured wordlists (combinator needs at least two) +hate_crack combinator hashes.txt 1000 --wordlist first.txt second.txt +hate_crack hybrid hashes.txt 1000 --wordlist rockyou.txt + +# Corporate masks, lengths 8-12 only +hate_crack corporate hashes.txt 1000 --min 8 --max 12 + +# Smart mask, skipping templates over 10 billion candidates +hate_crack smartmask hashes.txt 1000 --keyspace-limit 10000000000 +``` + +The rest take the one input their menu entry asks for: + +```bash +# Bandrel methodology (comma-separate multiple companies) +hate_crack bandrel hashes.txt 1000 --company "Acme,Acme Corp" + +# Permutation attack over a short targeted wordlist +hate_crack permute hashes.txt 1000 --wordlist names.txt + +# Ad-hoc mask -- a literal mask or a .hcmask file, optionally incrementing +hate_crack adhocmask hashes.txt 1000 --mask '?u?l?l?l?d?d' +hate_crack adhocmask hashes.txt 1000 --mask masks/corporate.hcmask +hate_crack adhocmask hashes.txt 1000 --mask '?a?a?a?a?a?a?a?a' \ + --increment-min 4 --increment-max 8 + +# N-gram candidates from a corpus +hate_crack ngram hashes.txt 1000 --corpus corpus.txt --group-size 3 + +# Combipow passphrases (wordlist capped at 63 lines: it generates 2^n-1) +hate_crack combipow hashes.txt 1000 --wordlist words.txt +hate_crack combipow hashes.txt 1000 --wordlist words.txt --no-spaces + +# Spoonman: basewords and rules derived from a corpus of known passwords +hate_crack spoonman hashes.txt 1000 --corpus previous-engagement.txt +hate_crack spoonman hashes.txt 1000 --corpus previous.txt --rule-coverage 95 + +# OMEN -- requires a model trained beforehand from the interactive menu +hate_crack omen hashes.txt 1000 --max-candidates 1000000 + +# Loopback: re-run rules against the plaintexts already cracked +hate_crack loopback hashes.txt 1000 --rules best64.rule +``` + +Five menu entries are still interactive-only, because they need input a single +flag cannot carry: Markov brute force, Random Rules, the Rosetta attack, the +LLM attack, and the Extensive Pure_Hate methodology (an orchestrator over the +others rather than a single attack). + +#### Exit codes + +| Code | Meaning | +|---|---| +| `0` | The attack ran | +| `1` | Bad input — missing hash file or wordlist, non-numeric hash type, unknown rule filename, out-of-range argument | +| `2` | Unknown subcommand — this build of hate_crack does not have it | +| `3` | Only with `--exit-code-on-skip`: coverage had already seen every pass, so nothing was launched | + +Exit `2` and exit `3` are what let a scripted driver tell "this version cannot +run that attack" and "it was redundant" apart from "it ran and found nothing". +`--exit-code-on-skip` is opt-in so that enabling coverage does not start +failing harnesses that predate it. + ------------------------------------------------------------------- ## Troubleshooting diff --git a/hate_crack/main.py b/hate_crack/main.py index a45b4fff..dd8a8b05 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -5306,19 +5306,30 @@ def hcatYoloCombination(hcatHashType, hcatHashFile): # Bandrel methodlogy -def hcatBandrel(hcatHashType, hcatHashFile): +def hcatBandrel(hcatHashType, hcatHashFile, company_name=None): + """Bandrel methodology: company-name basewords crossed with masks. + + ``company_name`` is comma-separated for multiple companies. Passing it + skips the prompt, which is what makes the attack drivable from the + ``bandrel`` non-interactive subcommand -- the prompt loop below cannot + take a default, so a scripted run would otherwise raise EOFError inside + it (issue #340). + """ global hcatProcess basewords = [] - while True: + while not (company_name or "").strip(): company_name = input( "What is the company name (Enter multiples comma separated)? " ) - if company_name: - break + # Stripped because the list is comma-separated: "Acme, Globex" otherwise + # yields " Globex", whose first character is a space, and the masks below + # are built from name[0]/name[1:]. for name in company_name.split(","): - basewords.append(name) + if name.strip(): + basewords.append(name.strip()) for word in bandrelbasewords.split(","): - basewords.append(word) + if word.strip(): + basewords.append(word.strip()) for name in basewords: mask1 = "-1{0}{1}".format(name[0].lower(), name[0].upper()) mask2 = " ?1{0}".format(name[1:]) diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index b52e703d..f6db02e5 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -3,12 +3,32 @@ 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). + +Every subcommand is one :class:`AttackSpec` in :data:`ATTACK_SPECS`. That table +is the single source of truth: ``ATTACK_COMMANDS`` is derived from it and both +``add_attack_subparsers`` and ``_dispatch`` are driven off it. Keeping the +three in one place matters because ``main.py`` sets its ``non_interactive`` +global from membership in ``ATTACK_COMMANDS`` -- a name registered as a +subparser but missing from that tuple would run the attack with every +interactive prompt still live, waiting on a stdin nobody is attached to. """ import os -from typing import Any +from typing import Any, Callable, NamedTuple + + +class AttackSpec(NamedTuple): + """One non-interactive subcommand. + + ``add_arguments`` receives the subparser (the shared ``hashfile`` and + ``hashtype`` positionals are added for it). ``run`` receives ``(ctx, + args)`` and returns a process exit code. + """ -ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") + name: str + help: str + add_arguments: Callable[[Any], None] | None + run: Callable[[Any, Any], int] def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: @@ -81,64 +101,323 @@ def run_noninteractive(ctx: Any, args: Any) -> int: def _dispatch(ctx: Any, args: Any) -> int: - command = args.command + spec = _SPECS_BY_NAME.get(getattr(args, "command", None)) + if spec is None: + print(f"Error: unknown non-interactive command: {args.command}") + return 2 + return spec.run(ctx, args) - 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 + +# --------------------------------------------------------------------------- +# Shared input validation +# +# Every one of these returns the resolved value or raises _BadInput, which the +# runners turn into exit 1. Validating up front rather than letting the attack +# function bail halfway matters for a scripted caller: a run that dies after +# launching looks the same as one that never started unless the exit code +# distinguishes them. +# --------------------------------------------------------------------------- + + +class _BadInput(Exception): + """A CLI argument that cannot produce a usable attack.""" + + +def _validated(fn: Callable[[Any, Any], int]) -> Callable[[Any, Any], int]: + """Turn a _BadInput raised by a runner into exit 1 plus a message.""" + + def _wrapper(ctx: Any, args: Any) -> int: try: - chains = build_rule_chains(ctx, args.rule_files) - except (FileNotFoundError, ValueError) as exc: - print(f"Error: invalid --rules value: {exc}") + return fn(ctx, args) + except _BadInput as exc: + print(f"Error: {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 + _wrapper.__name__ = fn.__name__ + _wrapper.__doc__ = fn.__doc__ + return _wrapper + + +def _existing_file(ctx: Any, raw: str, label: str) -> str: + path = ctx.resolve_path(raw) + if not path or not os.path.isfile(path): + raise _BadInput(f"{label} not found: {raw}") + return path + + +def _positive_int(value: int, label: str) -> int: + if value <= 0: + raise _BadInput(f"{label} must be greater than 0") + return value + + +def _rule_chains(ctx: Any, tokens: list[str] | None) -> list[str]: + try: + return build_rule_chains(ctx, tokens) + except (FileNotFoundError, ValueError) as exc: + raise _BadInput(f"invalid --rules value: {exc}") from exc - if command == "brute": - ctx.hcatBruteForce( - ctx.hcatHashType, ctx.hcatHashFile, args.min_len, args.max_len + +def _target(ctx: Any) -> tuple[Any, Any]: + return ctx.hcatHashType, ctx.hcatHashFile + + +# --------------------------------------------------------------------------- +# Runners -- the original four +# --------------------------------------------------------------------------- + + +@_validated +def _run_quick(ctx: Any, args: Any) -> int: + wordlist = _existing_file(ctx, args.wordlist, "wordlist") + for chain in _rule_chains(ctx, args.rule_files): + ctx.hcatQuickDictionary( + *_target(ctx), chain, wordlist, attack_name="Quick Crack" ) + return 0 + + +def _run_dict(ctx: Any, args: Any) -> int: + ctx.hcatDictionary(*_target(ctx)) + return 0 + + +def _run_brute(ctx: Any, args: Any) -> int: + ctx.hcatBruteForce(*_target(ctx), args.min_len, args.max_len) + return 0 + + +def _run_topmask(ctx: Any, args: Any) -> int: + ctx.hcatTopMask(*_target(ctx), args.target_time * 3600) + return 0 + + +# --------------------------------------------------------------------------- +# Runners -- no-argument tier (#340) +# --------------------------------------------------------------------------- + + +@_validated +def _run_fingerprint(ctx: Any, args: Any) -> int: + # The interactive prompt enforces 7-36 before calling; a scripted run that + # skipped the check would hand hashcat an expander length that quietly + # produces nothing rather than failing. + if not 7 <= args.max_expander_len <= 36: + raise _BadInput("--max-expander-len must be between 7 and 36") + if args.keyspace_limit is not None and args.keyspace_limit < 0: + raise _BadInput("--keyspace-limit must be zero (no limit) or greater") + + if args.no_dictionary_wordlist: + # "" means skip, which is distinct from None ("fall back to config"). + dictionary_wordlist: str | None = "" + elif args.dictionary_wordlist: + dictionary_wordlist = _existing_file( + ctx, args.dictionary_wordlist, "dictionary wordlist" + ) + else: + dictionary_wordlist = None + + ctx.hcatFingerprint( + *_target(ctx), + max_expander_len=args.max_expander_len, + run_hybrid_on_expanded=args.run_hybrid_on_expanded, + dictionary_wordlist=dictionary_wordlist, + keyspace_limit=args.keyspace_limit, + ) + return 0 + + +def _run_bare(func_name: str) -> Callable[[Any, Any], int]: + """Runner for an attack taking only hashtype + hashfile.""" + + def _run(ctx: Any, args: Any) -> int: + getattr(ctx, func_name)(*_target(ctx)) return 0 - if command == "topmask": - ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, args.target_time * 3600) + return _run + + +def _run_smartmask(ctx: Any, args: Any) -> int: + if args.keyspace_limit is not None and args.keyspace_limit < 0: + print("Error: --keyspace-limit must be zero (no limit) or greater") + return 1 + ctx.hcatSmartMask(*_target(ctx), keyspace_limit=args.keyspace_limit) + return 0 + + +def _run_corporate(ctx: Any, args: Any) -> int: + # Passed only when set, so hcatCorporateMasks applies its own documented + # defaults (and its own clamping to the 8-14 corporate range) rather than + # this module duplicating those constants. + kwargs = {} + if args.min_len is not None: + kwargs["minLen"] = args.min_len + if args.max_len is not None: + kwargs["maxLen"] = args.max_len + ctx.hcatCorporateMasks(*_target(ctx), **kwargs) + return 0 + + +def _wordlist_runner(func_name: str, minimum: int) -> Callable[[Any, Any], int]: + """Runner for an attack whose wordlists default to a configured list. + + ``None`` is the sentinel both hcatCombination and hcatHybrid use for "fall + back to config", so an unset --wordlist must pass None rather than []. + """ + + @_validated + def _run(ctx: Any, args: Any) -> int: + if not args.wordlists: + wordlists = None + else: + if len(args.wordlists) < minimum: + raise _BadInput( + f"--wordlist needs at least {minimum} entries for this attack" + ) + wordlists = [_existing_file(ctx, w, "wordlist") for w in args.wordlists] + getattr(ctx, func_name)(*_target(ctx), wordlists=wordlists) return 0 - print(f"Error: unknown non-interactive command: {command}") - return 2 + return _run -def add_attack_subparsers(subparsers) -> None: - """Register the non-interactive attack subcommands on an argparse - subparsers object (the same one used for ``hashview``). +# --------------------------------------------------------------------------- +# Runners -- one-argument tier (#340) +# --------------------------------------------------------------------------- - Each subcommand carries its own required ``hashfile`` + ``hashtype`` - positionals plus attack-specific flags. + +@_validated +def _run_bandrel(ctx: Any, args: Any) -> int: + company = args.company.strip() + if not company: + raise _BadInput("--company must not be blank") + ctx.hcatBandrel(*_target(ctx), company_name=company) + return 0 + + +@_validated +def _run_permute(ctx: Any, args: Any) -> int: + ctx.hcatPermute(*_target(ctx), _existing_file(ctx, args.wordlist, "wordlist")) + return 0 + + +@_validated +def _run_adhocmask(ctx: Any, args: Any) -> int: + inc_min = args.increment_min + inc_max = args.increment_max + increment = bool(inc_min or inc_max) + if inc_min and inc_max and int(inc_min) > int(inc_max): + raise _BadInput("--increment-min must not exceed --increment-max") + ctx.hcatAdHocMask( + *_target(ctx), + args.mask, + increment=increment, + increment_min=inc_min or "", + increment_max=inc_max or "", + ) + return 0 + + +@_validated +def _run_ngram(ctx: Any, args: Any) -> int: + corpus = _existing_file(ctx, args.corpus, "corpus") + ctx.hcatNgramX( + *_target(ctx), corpus, group_size=_positive_int(args.group_size, "--group-size") + ) + return 0 + + +#: combipow enumerates 2^n-1 combinations, so the interactive path refuses a +#: wordlist over this many lines. The scripted path refuses too rather than +#: launching a run that cannot finish. +COMBIPOW_MAX_LINES = 63 + + +@_validated +def _run_combipow(ctx: Any, args: Any) -> int: + wordlist = _existing_file(ctx, args.wordlist, "wordlist") + with ctx._open_wordlist(wordlist) as fh: + line_count = sum(1 for _ in fh) + if line_count > COMBIPOW_MAX_LINES: + raise _BadInput( + f"wordlist has {line_count} lines (max {COMBIPOW_MAX_LINES}); " + "combipow generates 2^n-1 combinations" + ) + ctx.hcatCombipow(*_target(ctx), wordlist, not args.no_spaces) + return 0 + + +@_validated +def _run_spoonman(ctx: Any, args: Any) -> int: + corpus = _existing_file(ctx, args.corpus, "corpus") + ctx.hcatSpoonman( + *_target(ctx), + corpus, + coverage=args.rule_coverage, + baseword_cap=args.baseword_cap, + ) + return 0 + + +@_validated +def _run_omen(ctx: Any, args: Any) -> int: + max_candidates = _positive_int(args.max_candidates, "--max-candidates") + # The interactive handler offers to train a model here. Training needs its + # own corpus and runs for a long time, so a scripted caller that asked for + # an attack gets an error rather than a surprise training run. + if not ctx._omen_model_is_valid(ctx._omen_model_dir()): + raise _BadInput( + "no valid OMEN model found; train one from the interactive menu " + "(option 13) before running this subcommand" + ) + ctx.hcatOmen(*_target(ctx), max_candidates) + return 0 + + +@_validated +def _run_loopback(ctx: Any, args: Any) -> int: + """Re-run rules against the plaintexts already cracked for this hash file. + + Mirrors ``attacks.loopback_attack``: an empty wordlist plus + ``hcatQuickDictionary(loopback=True)``, which makes hashcat feed its own + potfile back in. It deliberately does not call ``hcatRecycle`` -- that + function's third argument is a count of newly cracked passwords used as an + internal gate by ``extensive_crack``, and has no meaning for a caller + choosing to run a loopback pass. """ + chains = _rule_chains(ctx, args.rule_files) - 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)") + empty_wordlist = os.path.join(ctx.hcatWordlists, "empty.txt") + os.makedirs(ctx.hcatWordlists, exist_ok=True) + if not os.path.exists(empty_wordlist): + with open(empty_wordlist, "w"): + pass - quick = subparsers.add_parser( - "quick", help="Non-interactive quick crack (single wordlist + optional rules)" + # Primed once against the combined coverage of every selected rule file, so + # the skip decision reflects the whole batch rather than the first chain's + # numbers alone -- same reason attacks.loopback_attack does it. + coverage_decision = ctx._prime_coverage_decision( + ctx.hcatHashFile, chains, empty_wordlist, "Loopback", loopback=True ) - _add_target(quick) - quick.add_argument("--wordlist", required=True, help="Path to wordlist file") - quick.add_argument( + for chain in chains: + ctx.hcatQuickDictionary( + *_target(ctx), + chain, + empty_wordlist, + loopback=True, + attack_name="Loopback", + coverage_decision=coverage_decision, + ) + return 0 + + +# --------------------------------------------------------------------------- +# Argument builders +# --------------------------------------------------------------------------- + + +def _add_rules(p) -> None: + p.add_argument( "--rules", nargs="*", default=[], @@ -148,29 +427,321 @@ def _add_target(p): "(e.g. best64.rule+d3ad0ne.rule). Omit to run without rules.", ) - dictp = subparsers.add_parser( - "dict", - help="Non-interactive dictionary methodology (uses configured wordlists)", + +def _add_keyspace_limit(p, subject: str) -> None: + p.add_argument( + "--keyspace-limit", + type=int, + default=None, + dest="keyspace_limit", + help=f"Skip a {subject} that would exceed this many candidates " + "(0 for no limit; omit for the built-in default of 50,000,000,000).", ) - _add_target(dictp) - brute = subparsers.add_parser( - "brute", help="Non-interactive brute force (mask) attack" + +def _add_wordlists(p, subject: str) -> None: + p.add_argument( + "--wordlist", + nargs="+", + default=[], + dest="wordlists", + metavar="PATH", + help=f"Wordlist(s) for the {subject}. Omit to use the configured " + "default from config.json.", ) - _add_target(brute) - brute.add_argument( + + +def _quick_args(p) -> None: + p.add_argument("--wordlist", required=True, help="Path to wordlist file") + _add_rules(p) + + +def _brute_args(p) -> None: + p.add_argument( "--min", type=int, default=1, dest="min_len", help="Minimum length (default 1)" ) - brute.add_argument( + p.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( + +def _topmask_args(p) -> None: + p.add_argument( "--target-time", type=int, default=4, dest="target_time", help="Target completion time in hours (default 4)", ) + + +def _fingerprint_args(p) -> None: + p.add_argument( + "--max-expander-len", + type=int, + default=21, + dest="max_expander_len", + help="Maximum expander fragment length to escalate to, 7-36 (default 21)", + ) + p.add_argument( + "--run-hybrid-on-expanded", + action="store_true", + dest="run_hybrid_on_expanded", + help="Also run hybrid passes over the expanded fragments", + ) + p.add_argument( + "--dictionary-wordlist", + default=None, + dest="dictionary_wordlist", + help="Wordlist to combine expanded fragments against. Omit to use the " + "configured default.", + ) + p.add_argument( + "--no-dictionary-wordlist", + action="store_true", + dest="no_dictionary_wordlist", + help="Skip the fragment/wordlist combination step entirely, rather " + "than falling back to the configured wordlist.", + ) + _add_keyspace_limit(p, "combination step") + + +def _smartmask_args(p) -> None: + _add_keyspace_limit(p, "template") + + +def _corporate_args(p) -> None: + p.add_argument( + "--min", + type=int, + default=None, + dest="min_len", + help="Minimum mask length (clamped to the corporate 8-14 range)", + ) + p.add_argument( + "--max", + type=int, + default=None, + dest="max_len", + help="Maximum mask length (clamped to the corporate 8-14 range)", + ) + + +def _bandrel_args(p) -> None: + p.add_argument( + "--company", + required=True, + help="Company name(s), comma separated for multiples", + ) + + +def _permute_args(p) -> None: + p.add_argument( + "--wordlist", + required=True, + help="Path to a short targeted wordlist (scales as N! per word)", + ) + + +def _adhocmask_args(p) -> None: + p.add_argument( + "--mask", + required=True, + help="A hashcat mask (e.g. ?u?l?l?l?d?d) or a path to a .hcmask file", + ) + p.add_argument( + "--increment-min", + default="", + dest="increment_min", + help="Enable mask increment starting at this length", + ) + p.add_argument( + "--increment-max", + default="", + dest="increment_max", + help="Enable mask increment stopping at this length", + ) + + +def _ngram_args(p) -> None: + p.add_argument("--corpus", required=True, help="Path to the corpus file") + p.add_argument( + "--group-size", + type=int, + default=3, + dest="group_size", + help="N-gram group size (default 3)", + ) + + +def _combipow_args(p) -> None: + p.add_argument( + "--wordlist", + required=True, + help=f"Path to a wordlist of at most {COMBIPOW_MAX_LINES} lines", + ) + p.add_argument( + "--no-spaces", + action="store_true", + dest="no_spaces", + help="Join words directly instead of separating them with spaces", + ) + + +def _spoonman_args(p) -> None: + p.add_argument( + "--corpus", + required=True, + help="Path to a password corpus to derive basewords and rules from", + ) + p.add_argument( + "--rule-coverage", + type=int, + default=None, + dest="rule_coverage", + help="Use the capped rule file reaching this percent of the corpus " + "(e.g. 95); omit for the full rule set.", + ) + p.add_argument( + "--baseword-cap", + type=int, + default=None, + dest="baseword_cap", + help="Keep at most this many derived basewords", + ) + + +def _omen_args(p) -> None: + p.add_argument( + "--max-candidates", + type=int, + required=True, + dest="max_candidates", + help="Maximum number of candidates for OMEN to enumerate", + ) + + +# --------------------------------------------------------------------------- +# The table +# --------------------------------------------------------------------------- + +ATTACK_SPECS: tuple[AttackSpec, ...] = ( + AttackSpec( + "quick", + "Non-interactive quick crack (single wordlist + optional rules)", + _quick_args, + _run_quick, + ), + AttackSpec( + "dict", + "Non-interactive dictionary methodology (uses configured wordlists)", + None, + _run_dict, + ), + AttackSpec( + "brute", "Non-interactive brute force (mask) attack", _brute_args, _run_brute + ), + AttackSpec( + "topmask", "Non-interactive top-mask attack", _topmask_args, _run_topmask + ), + # --- no-argument tier --- + AttackSpec( + "fingerprint", + "Fingerprint attack (expand cracked plaintexts into fragments)", + _fingerprint_args, + _run_fingerprint, + ), + AttackSpec( + "combinator", + "Combinator attack (concatenate two or more wordlists)", + lambda p: _add_wordlists(p, "combinator attack (at least two)"), + _wordlist_runner("hcatCombination", minimum=2), + ), + AttackSpec( + "hybrid", + "Hybrid attack (wordlist + mask, hashcat modes 6 and 7)", + lambda p: _add_wordlists(p, "hybrid attack"), + _wordlist_runner("hcatHybrid", minimum=1), + ), + AttackSpec( + "pathwell", + "Pathwell top-100 mask brute force", + None, + _run_bare("hcatPathwellBruteForce"), + ), + AttackSpec("prince", "PRINCE attack", None, _run_bare("hcatPrince")), + AttackSpec("pcfg", "PCFG attack", None, _run_bare("hcatPCFG")), + AttackSpec("princeling", "PRINCE-LING attack", None, _run_bare("hcatPrinceLing")), + AttackSpec( + "smartmask", + "Smart mask attack (masks derived from cracked plaintext patterns)", + _smartmask_args, + _run_smartmask, + ), + AttackSpec( + "corporate", + "Corporate masks brute force (statistical 8-14 character masks)", + _corporate_args, + _run_corporate, + ), + # --- one-argument tier --- + AttackSpec( + "bandrel", + "Bandrel methodology (company-name basewords plus masks)", + _bandrel_args, + _run_bandrel, + ), + AttackSpec( + "permute", + "Permutation attack (all character permutations of each word)", + _permute_args, + _run_permute, + ), + AttackSpec("adhocmask", "Ad-hoc mask attack", _adhocmask_args, _run_adhocmask), + AttackSpec( + "ngram", + "N-gram attack (candidates generated from a corpus)", + _ngram_args, + _run_ngram, + ), + AttackSpec( + "combipow", + "Combipow passphrase attack (all combinations of a short wordlist)", + _combipow_args, + _run_combipow, + ), + AttackSpec( + "spoonman", + "Spoonman attack (basewords and rules derived from a corpus)", + _spoonman_args, + _run_spoonman, + ), + AttackSpec( + "omen", "OMEN attack (Ordered Markov ENumerator)", _omen_args, _run_omen + ), + AttackSpec( + "loopback", + "Loopback attack (re-run rules against already-cracked plaintexts)", + _add_rules, + _run_loopback, + ), +) + +ATTACK_COMMANDS: tuple[str, ...] = tuple(spec.name for spec in ATTACK_SPECS) + +_SPECS_BY_NAME: dict[str, AttackSpec] = {spec.name: spec for spec in ATTACK_SPECS} + + +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. + """ + for spec in ATTACK_SPECS: + parser = subparsers.add_parser(spec.name, help=spec.help) + parser.add_argument("hashfile", help="Path to hash file to crack") + parser.add_argument("hashtype", help="Hashcat hash type (e.g. 1000 for NTLM)") + if spec.add_arguments is not None: + spec.add_arguments(parser) diff --git a/tests/test_bandrel_company_arg.py b/tests/test_bandrel_company_arg.py new file mode 100644 index 00000000..a4911a3d --- /dev/null +++ b/tests/test_bandrel_company_arg.py @@ -0,0 +1,92 @@ +"""hcatBandrel's company name becomes a parameter (issue #340). + +The attack previously read the company name from a blocking `while True: +input(...)` loop, which is why it could not be driven non-interactively: a +scripted run raises EOFError inside the loop rather than taking a default. +Adding the parameter must not change what an operator at the menu sees, so +both halves are pinned here. +""" + +import subprocess +from unittest.mock import patch + +import pytest + +import hate_crack.main as hc_main + + +@pytest.fixture +def stub_hashcat(monkeypatch, tmp_path): + """Capture the hashcat argv lists without launching anything.""" + launched = [] + + class _Proc: + returncode = 0 + + def communicate(self, *a, **k): + return (b"", b"") + + def wait(self, *a, **k): + return 0 + + def poll(self): + return 0 + + def _popen(cmd, *a, **k): + launched.append(cmd) + return _Proc() + + monkeypatch.setattr(subprocess, "Popen", _popen) + monkeypatch.setattr(hc_main, "bandrelbasewords", "summer,winter") + return launched + + +def test_company_name_argument_skips_the_prompt(stub_hashcat, tmp_path): + hashfile = str(tmp_path / "hashes.txt") + with patch( + "builtins.input", side_effect=AssertionError("prompted despite company_name") + ): + hc_main.hcatBandrel("1000", hashfile, company_name="Acme") + assert stub_hashcat, "no hashcat invocation was built" + + +def _mask_tokens(launched): + """The per-baseword custom charset, e.g. "-1aA" for "Acme" -- one argv + token, not a flag/value pair.""" + return {t for cmd in launched for t in cmd if str(t).startswith("-1")} + + +def test_company_name_is_split_on_commas(stub_hashcat, tmp_path): + hashfile = str(tmp_path / "hashes.txt") + with patch("builtins.input", side_effect=AssertionError("prompted")): + hc_main.hcatBandrel("1000", hashfile, company_name="Acme,Globex") + # Two companies plus the two stubbed static basewords. + assert _mask_tokens(stub_hashcat) == {"-1aA", "-1gG", "-1sS", "-1wW"} + + +def test_interactive_path_still_prompts(stub_hashcat, tmp_path): + """Regression guard: the menu handler calls hcatBandrel with no company, + and must still get the prompt.""" + hashfile = str(tmp_path / "hashes.txt") + with patch("builtins.input", return_value="Acme") as mock_input: + hc_main.hcatBandrel("1000", hashfile) + assert mock_input.called + + +def test_interactive_prompt_still_rejects_an_empty_answer(stub_hashcat, tmp_path): + """The original loop re-asked until the answer was non-empty.""" + hashfile = str(tmp_path / "hashes.txt") + with patch("builtins.input", side_effect=["", " ", "Acme"]) as mock_input: + hc_main.hcatBandrel("1000", hashfile) + assert mock_input.call_count == 3 + + +def test_comma_separated_names_are_stripped(stub_hashcat, tmp_path): + """ "Acme, Globex" must not build a mask from a leading space -- + the masks are derived from name[0] and name[1:].""" + hashfile = str(tmp_path / "hashes.txt") + with patch("builtins.input", side_effect=AssertionError("prompted")): + hc_main.hcatBandrel("1000", hashfile, company_name="Acme, Globex") + masks = _mask_tokens(stub_hashcat) + assert not any(m.startswith("-1 ") for m in masks), masks + assert "-1gG" in masks diff --git a/tests/test_noninteractive_attacks.py b/tests/test_noninteractive_attacks.py new file mode 100644 index 00000000..b3f8a9a8 --- /dev/null +++ b/tests/test_noninteractive_attacks.py @@ -0,0 +1,790 @@ +"""Non-interactive subcommands for the menu attacks (issue #340). + +``tests/test_noninteractive.py`` covers the original four commands (quick, +dict, brute, topmask) and the ``build_rule_chains`` grammar. This file covers +the seventeen added for #340 and the spec table that drives them. +""" + +import os +from types import SimpleNamespace + +import pytest + +from hate_crack import noninteractive as ni + + +# -------------------------------------------------------------------------- +# Spec table contract +# -------------------------------------------------------------------------- + + +def test_attack_commands_is_derived_from_the_spec_table(): + """ATTACK_COMMANDS must not be a hand-maintained tuple that can drift from + the dispatcher -- main.py keys `non_interactive` off membership in it, so a + name present in one and absent from the other silently runs an attack with + the interactive prompts still live.""" + assert ni.ATTACK_COMMANDS == tuple(spec.name for spec in ni.ATTACK_SPECS) + + +def test_every_spec_has_a_runner_and_unique_name(): + names = [spec.name for spec in ni.ATTACK_SPECS] + assert len(names) == len(set(names)), "duplicate subcommand name" + for spec in ni.ATTACK_SPECS: + assert callable(spec.run), f"{spec.name} has no runner" + assert spec.help, f"{spec.name} has no help text" + + +def test_every_spec_registers_a_subparser(): + import argparse + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + ni.add_attack_subparsers(subparsers) + registered = set(subparsers.choices) + assert set(ni.ATTACK_COMMANDS) <= registered + + +def test_issue_340_attacks_are_all_present(): + """The seventeen names #340 asks for, plus the original four.""" + expected = { + "quick", + "dict", + "brute", + "topmask", + # no-argument tier + "fingerprint", + "combinator", + "hybrid", + "pathwell", + "prince", + "pcfg", + "princeling", + "smartmask", + "corporate", + # one-argument tier + "bandrel", + "permute", + "adhocmask", + "ngram", + "combipow", + "spoonman", + "omen", + "loopback", + } + assert set(ni.ATTACK_COMMANDS) == expected + + +def test_unknown_command_returns_2(): + """#340 consistency note 2: exit 2 must never be conflated with "ran". + A caller pointed at an older hate_crack gets 2 on every invocation.""" + ctx = SimpleNamespace() + assert ni.run_noninteractive(ctx, SimpleNamespace(command="no-such-attack")) == 2 + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + + +def _spy_ctx(tmp_path, **overrides): + """A stand-in for the main module recording every hcat* call.""" + calls = [] + + def rec(name): + def _fn(*a, **k): + calls.append((name, a, k)) + + return _fn + + wordlists = tmp_path / "wordlists" + wordlists.mkdir(exist_ok=True) + rules = tmp_path / "rules" + rules.mkdir(exist_ok=True) + + ctx = SimpleNamespace( + calls=calls, + hcatHashType="1000", + hcatHashFile=str(tmp_path / "hashes.txt"), + rulesDirectory=str(rules), + hcatWordlists=str(wordlists), + hcatFingerprintWordlist=[], + hcatCombinationWordlist=[], + hcatHybridlist=[], + resolve_path=lambda p: os.path.abspath(os.path.expanduser(p)) if p else None, + _prime_coverage_decision=lambda *a, **k: {}, + _omen_model_dir=lambda: str(tmp_path / "omen-model"), + _omen_model_is_valid=lambda d: True, + _open_wordlist=lambda p: open(p), + ) + for name in ( + "hcatFingerprint", + "hcatCombination", + "hcatHybrid", + "hcatPathwellBruteForce", + "hcatPrince", + "hcatPCFG", + "hcatPrinceLing", + "hcatSmartMask", + "hcatCorporateMasks", + "hcatBandrel", + "hcatPermute", + "hcatAdHocMask", + "hcatNgramX", + "hcatCombipow", + "hcatSpoonman", + "hcatOmen", + "hcatQuickDictionary", + ): + setattr(ctx, name, rec(name)) + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + +def _parse(argv): + """Parse through the real subparsers, so defaults are the shipped ones.""" + import argparse + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command") + ni.add_attack_subparsers(subparsers) + return parser.parse_args(argv) + + +def _wordlist(tmp_path, name, lines=("password",)): + p = tmp_path / name + p.write_text("\n".join(lines) + "\n") + return p + + +# -------------------------------------------------------------------------- +# No-argument tier +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "command,func", + [ + ("pathwell", "hcatPathwellBruteForce"), + ("prince", "hcatPrince"), + ("pcfg", "hcatPCFG"), + ("princeling", "hcatPrinceLing"), + ], +) +def test_bare_attacks_dispatch_with_hashtype_and_hashfile(tmp_path, command, func): + ctx = _spy_ctx(tmp_path) + args = _parse([command, ctx.hcatHashFile, "1000"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls == [(func, ("1000", ctx.hcatHashFile), {})] + + +def test_fingerprint_passes_every_flag_through(tmp_path): + wl = _wordlist(tmp_path, "frags.txt") + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "fingerprint", + ctx.hcatHashFile, + "1000", + "--max-expander-len", + "24", + "--run-hybrid-on-expanded", + "--dictionary-wordlist", + str(wl), + "--keyspace-limit", + "1000", + ] + ) + assert ni.run_noninteractive(ctx, args) == 0 + name, a, k = ctx.calls[0] + assert name == "hcatFingerprint" + assert a == ("1000", ctx.hcatHashFile) + assert k["max_expander_len"] == 24 + assert k["run_hybrid_on_expanded"] is True + assert k["dictionary_wordlist"] == os.path.abspath(str(wl)) + assert k["keyspace_limit"] == 1000 + + +def test_fingerprint_defaults_match_the_function_defaults(tmp_path): + """A bare `fingerprint` must reproduce hcatFingerprint's own defaults -- + None for the wordlist means "fall back to config", which is not the same + as "" (skip).""" + ctx = _spy_ctx(tmp_path) + args = _parse(["fingerprint", ctx.hcatHashFile, "1000"]) + assert ni.run_noninteractive(ctx, args) == 0 + _, _, k = ctx.calls[0] + assert k["max_expander_len"] == 21 + assert k["run_hybrid_on_expanded"] is False + assert k["dictionary_wordlist"] is None + assert k["keyspace_limit"] is None + + +def test_fingerprint_no_dictionary_wordlist_skips_rather_than_defaulting(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["fingerprint", ctx.hcatHashFile, "1000", "--no-dictionary-wordlist"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][2]["dictionary_wordlist"] == "" + + +@pytest.mark.parametrize("bad", ["6", "37", "0"]) +def test_fingerprint_rejects_out_of_range_expander_length(tmp_path, bad): + """The interactive prompt enforces 7-36; the scripted path must too, + rather than handing hashcat a length that silently does nothing.""" + ctx = _spy_ctx(tmp_path) + args = _parse(["fingerprint", ctx.hcatHashFile, "1000", "--max-expander-len", bad]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_fingerprint_rejects_negative_keyspace_limit(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["fingerprint", ctx.hcatHashFile, "1000", "--keyspace-limit", "-1"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_fingerprint_missing_dictionary_wordlist_returns_1(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "fingerprint", + ctx.hcatHashFile, + "1000", + "--dictionary-wordlist", + str(tmp_path / "nope.txt"), + ] + ) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_smartmask_passes_keyspace_limit(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["smartmask", ctx.hcatHashFile, "1000", "--keyspace-limit", "500"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ( + "hcatSmartMask", + ("1000", ctx.hcatHashFile), + {"keyspace_limit": 500}, + ) + + +def test_smartmask_defaults_to_none(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["smartmask", ctx.hcatHashFile, "1000"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][2]["keyspace_limit"] is None + + +def test_corporate_omits_unset_lengths_so_function_defaults_apply(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["corporate", ctx.hcatHashFile, "1000"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatCorporateMasks", ("1000", ctx.hcatHashFile), {}) + + +def test_corporate_passes_explicit_lengths(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["corporate", ctx.hcatHashFile, "1000", "--min", "9", "--max", "12"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][2] == {"minLen": 9, "maxLen": 12} + + +@pytest.mark.parametrize( + "command,func,config_attr", + [ + ("combinator", "hcatCombination", "hcatCombinationWordlist"), + ("hybrid", "hcatHybrid", "hcatHybridlist"), + ], +) +def test_wordlist_attacks_default_to_config(tmp_path, command, func, config_attr): + """No --wordlist means None, which is the sentinel each function uses to + fall back to its configured list.""" + ctx = _spy_ctx(tmp_path) + args = _parse([command, ctx.hcatHashFile, "1000"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == (func, ("1000", ctx.hcatHashFile), {"wordlists": None}) + + +def test_combinator_accepts_multiple_wordlists(tmp_path): + a = _wordlist(tmp_path, "a.txt") + b = _wordlist(tmp_path, "b.txt") + ctx = _spy_ctx(tmp_path) + args = _parse( + ["combinator", ctx.hcatHashFile, "1000", "--wordlist", str(a), str(b)] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][2]["wordlists"] == [ + os.path.abspath(str(a)), + os.path.abspath(str(b)), + ] + + +def test_combinator_rejects_a_single_wordlist(tmp_path): + """hcatCombination needs at least two; one would abort inside the attack + after the run had already been reported as started.""" + a = _wordlist(tmp_path, "a.txt") + ctx = _spy_ctx(tmp_path) + args = _parse(["combinator", ctx.hcatHashFile, "1000", "--wordlist", str(a)]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_hybrid_accepts_a_single_wordlist(tmp_path): + a = _wordlist(tmp_path, "a.txt") + ctx = _spy_ctx(tmp_path) + args = _parse(["hybrid", ctx.hcatHashFile, "1000", "--wordlist", str(a)]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][2]["wordlists"] == [os.path.abspath(str(a))] + + +def test_wordlist_attacks_reject_a_missing_file(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "combinator", + ctx.hcatHashFile, + "1000", + "--wordlist", + str(tmp_path / "gone.txt"), + str(tmp_path / "also-gone.txt"), + ] + ) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +# -------------------------------------------------------------------------- +# One-argument tier +# -------------------------------------------------------------------------- + + +def test_bandrel_passes_company_without_prompting(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["bandrel", ctx.hcatHashFile, "1000", "--company", "Acme,Acme Corp"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ( + "hcatBandrel", + ("1000", ctx.hcatHashFile), + {"company_name": "Acme,Acme Corp"}, + ) + + +def test_bandrel_requires_company(tmp_path): + with pytest.raises(SystemExit): + _parse(["bandrel", str(tmp_path / "h.txt"), "1000"]) + + +def test_bandrel_rejects_a_blank_company(tmp_path): + """The interactive loop refuses an empty answer; --company "" must not + slip past it into a baseword list built from nothing.""" + ctx = _spy_ctx(tmp_path) + args = _parse(["bandrel", ctx.hcatHashFile, "1000", "--company", " "]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_permute_dispatches_with_wordlist(tmp_path): + wl = _wordlist(tmp_path, "names.txt") + ctx = _spy_ctx(tmp_path) + args = _parse(["permute", ctx.hcatHashFile, "1000", "--wordlist", str(wl)]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ( + "hcatPermute", + ("1000", ctx.hcatHashFile, os.path.abspath(str(wl))), + {}, + ) + + +def test_permute_missing_wordlist_returns_1(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse( + ["permute", ctx.hcatHashFile, "1000", "--wordlist", str(tmp_path / "no.txt")] + ) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_adhocmask_dispatches_a_literal_mask(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["adhocmask", ctx.hcatHashFile, "1000", "--mask", "?u?l?l?d?d"]) + assert ni.run_noninteractive(ctx, args) == 0 + name, a, k = ctx.calls[0] + assert name == "hcatAdHocMask" + assert a == ("1000", ctx.hcatHashFile, "?u?l?l?d?d") + assert k["increment"] is False + + +def test_adhocmask_increment_flags_enable_increment(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "adhocmask", + ctx.hcatHashFile, + "1000", + "--mask", + "?a?a?a?a", + "--increment-min", + "4", + "--increment-max", + "8", + ] + ) + assert ni.run_noninteractive(ctx, args) == 0 + k = ctx.calls[0][2] + assert k["increment"] is True + assert k["increment_min"] == "4" + assert k["increment_max"] == "8" + + +def test_adhocmask_rejects_inverted_increment_range(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "adhocmask", + ctx.hcatHashFile, + "1000", + "--mask", + "?a?a", + "--increment-min", + "9", + "--increment-max", + "3", + ] + ) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_adhocmask_accepts_an_hcmask_file(tmp_path): + maskfile = tmp_path / "corp.hcmask" + maskfile.write_text("?u?l?l?l?d?d?d\n") + ctx = _spy_ctx(tmp_path) + args = _parse(["adhocmask", ctx.hcatHashFile, "1000", "--mask", str(maskfile)]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][1][2] == str(maskfile) + + +def test_ngram_dispatches_with_corpus_and_group_size(tmp_path): + corpus = _wordlist(tmp_path, "corpus.txt") + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "ngram", + ctx.hcatHashFile, + "1000", + "--corpus", + str(corpus), + "--group-size", + "4", + ] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ( + "hcatNgramX", + ("1000", ctx.hcatHashFile, os.path.abspath(str(corpus))), + {"group_size": 4}, + ) + + +def test_ngram_group_size_defaults_to_3(tmp_path): + corpus = _wordlist(tmp_path, "corpus.txt") + ctx = _spy_ctx(tmp_path) + args = _parse(["ngram", ctx.hcatHashFile, "1000", "--corpus", str(corpus)]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][2]["group_size"] == 3 + + +def test_combipow_dispatches_with_spaces_by_default(tmp_path): + wl = _wordlist(tmp_path, "words.txt", ["alpha", "beta", "gamma"]) + ctx = _spy_ctx(tmp_path) + args = _parse(["combipow", ctx.hcatHashFile, "1000", "--wordlist", str(wl)]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][1][3] is True + + +def test_combipow_no_spaces_flag(tmp_path): + wl = _wordlist(tmp_path, "words.txt", ["alpha", "beta"]) + ctx = _spy_ctx(tmp_path) + args = _parse( + ["combipow", ctx.hcatHashFile, "1000", "--wordlist", str(wl), "--no-spaces"] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][1][3] is False + + +def test_combipow_rejects_an_oversized_wordlist(tmp_path): + """combipow generates 2^n-1 combinations; the interactive path refuses + over 63 lines and the scripted path must too rather than launching a run + that cannot finish.""" + wl = _wordlist(tmp_path, "big.txt", [f"w{i}" for i in range(64)]) + ctx = _spy_ctx(tmp_path) + args = _parse(["combipow", ctx.hcatHashFile, "1000", "--wordlist", str(wl)]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_combipow_accepts_exactly_63_lines(tmp_path): + wl = _wordlist(tmp_path, "edge.txt", [f"w{i}" for i in range(63)]) + ctx = _spy_ctx(tmp_path) + args = _parse(["combipow", ctx.hcatHashFile, "1000", "--wordlist", str(wl)]) + assert ni.run_noninteractive(ctx, args) == 0 + + +def test_spoonman_dispatches_with_corpus(tmp_path): + corpus = _wordlist(tmp_path, "cracked.txt") + ctx = _spy_ctx(tmp_path) + args = _parse(["spoonman", ctx.hcatHashFile, "1000", "--corpus", str(corpus)]) + assert ni.run_noninteractive(ctx, args) == 0 + name, a, k = ctx.calls[0] + assert name == "hcatSpoonman" + assert a == ("1000", ctx.hcatHashFile, os.path.abspath(str(corpus))) + assert k == {"coverage": None, "baseword_cap": None} + + +def test_spoonman_passes_rule_coverage_and_baseword_cap(tmp_path): + corpus = _wordlist(tmp_path, "cracked.txt") + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "spoonman", + ctx.hcatHashFile, + "1000", + "--corpus", + str(corpus), + "--rule-coverage", + "95", + "--baseword-cap", + "5000", + ] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][2] == {"coverage": 95, "baseword_cap": 5000} + + +def test_omen_dispatches_with_max_candidates(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["omen", ctx.hcatHashFile, "1000", "--max-candidates", "1000000"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ( + "hcatOmen", + ("1000", ctx.hcatHashFile, 1000000), + {}, + ) + + +def test_omen_without_a_trained_model_returns_1(tmp_path): + """Training needs a corpus and is a long separate operation, so the + scripted path refuses rather than silently training.""" + ctx = _spy_ctx(tmp_path, _omen_model_is_valid=lambda d: False) + args = _parse(["omen", ctx.hcatHashFile, "1000", "--max-candidates", "100"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_omen_rejects_non_positive_max_candidates(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["omen", ctx.hcatHashFile, "1000", "--max-candidates", "0"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +# -------------------------------------------------------------------------- +# Loopback -- mirrors attacks.loopback_attack, NOT hcatRecycle +# -------------------------------------------------------------------------- + + +def test_loopback_runs_quick_dictionary_against_an_empty_wordlist(tmp_path): + """Menu option 11 builds an empty wordlist and re-runs rules against the + already-cracked plaintexts via hcatQuickDictionary(loopback=True). It does + not call hcatRecycle, whose count argument is an internal gate used only by + extensive_crack.""" + rules = tmp_path / "rules" + rules.mkdir() + (rules / "best64.rule").write_text(":\n") + ctx = _spy_ctx(tmp_path) + args = _parse(["loopback", ctx.hcatHashFile, "1000", "--rules", "best64.rule"]) + assert ni.run_noninteractive(ctx, args) == 0 + name, a, k = ctx.calls[0] + assert name == "hcatQuickDictionary" + assert k["loopback"] is True + assert k["attack_name"] == "Loopback" + empty = a[3] + assert os.path.basename(empty) == "empty.txt" + assert os.path.isfile(empty) + assert os.path.getsize(empty) == 0 + + +def test_loopback_runs_each_rule_token_as_its_own_pass(tmp_path): + rules = tmp_path / "rules" + rules.mkdir() + (rules / "best64.rule").write_text(":\n") + (rules / "d3ad0ne.rule").write_text(":\n") + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "loopback", + ctx.hcatHashFile, + "1000", + "--rules", + "best64.rule", + "d3ad0ne.rule", + ] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert len(ctx.calls) == 2 + + +def test_loopback_shares_one_coverage_decision_across_passes(tmp_path): + """attacks.loopback_attack primes the decision once so the skip prompt + reflects the whole batch; the scripted path must pass the same object.""" + rules = tmp_path / "rules" + rules.mkdir() + (rules / "a.rule").write_text(":\n") + (rules / "b.rule").write_text(":\n") + sentinel = {"primed": True} + ctx = _spy_ctx(tmp_path, _prime_coverage_decision=lambda *a, **k: sentinel) + args = _parse(["loopback", ctx.hcatHashFile, "1000", "--rules", "a.rule", "b.rule"]) + assert ni.run_noninteractive(ctx, args) == 0 + assert [c[2]["coverage_decision"] for c in ctx.calls] == [sentinel, sentinel] + + +def test_loopback_unknown_rule_returns_1(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse(["loopback", ctx.hcatHashFile, "1000", "--rules", "ghost.rule"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +# -------------------------------------------------------------------------- +# --exit-code-on-skip applies uniformly to the new commands +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "argv", + [ + ["fingerprint"], + ["prince"], + ["smartmask"], + ["corporate"], + ["permute", "--wordlist", "WL"], + ["ngram", "--corpus", "WL"], + ], +) +def test_exit_code_on_skip_applies_to_new_commands(tmp_path, argv): + """#340 consistency note 1: exit 3 when coverage judged the attack + redundant and launched nothing.""" + wl = _wordlist(tmp_path, "wl.txt") + argv = [a if a != "WL" else str(wl) for a in argv] + ctx = _spy_ctx(tmp_path) + ctx.reset_run_counters = lambda: None + ctx.run_counters = lambda: (0, 3) # nothing launched, three skipped + args = _parse([argv[0], ctx.hcatHashFile, "1000"] + argv[1:]) + args.exit_code_on_skip = True + assert ni.run_noninteractive(ctx, args) == ni.SKIPPED_BY_COVERAGE + + +def test_skip_exit_code_is_not_returned_without_the_flag(tmp_path): + ctx = _spy_ctx(tmp_path) + ctx.reset_run_counters = lambda: None + ctx.run_counters = lambda: (0, 3) + args = _parse(["prince", ctx.hcatHashFile, "1000"]) + assert ni.run_noninteractive(ctx, args) == 0 + + +def test_skip_exit_code_not_returned_when_something_launched(tmp_path): + ctx = _spy_ctx(tmp_path) + ctx.reset_run_counters = lambda: None + ctx.run_counters = lambda: (1, 2) + args = _parse(["prince", ctx.hcatHashFile, "1000"]) + args.exit_code_on_skip = True + assert ni.run_noninteractive(ctx, args) == 0 + + +# -------------------------------------------------------------------------- +# End-to-end through main(), which is where `non_interactive` gets set +# -------------------------------------------------------------------------- + + +def _run_main(monkeypatch, argv): + import sys + + import hate_crack.main as hc_main + + 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. + 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 _ntlm_hashfile(tmp_path): + hf = tmp_path / "hashes.txt" + hf.write_text("aad3b435b51404eeaad3b435b51404ee\n") + return hf + + +@pytest.mark.parametrize( + "command,func", + [ + ("fingerprint", "hcatFingerprint"), + ("prince", "hcatPrince"), + ("pcfg", "hcatPCFG"), + ("pathwell", "hcatPathwellBruteForce"), + ("smartmask", "hcatSmartMask"), + ("corporate", "hcatCorporateMasks"), + ], +) +def test_main_dispatches_new_commands_without_prompting( + monkeypatch, tmp_path, command, func +): + """Membership in ATTACK_COMMANDS is what sets main()'s `non_interactive` + global, which in turn suppresses the coverage prompts these attacks reach + via _prompt_coverage_filter. If a name were registered as a subparser but + missing from the tuple, this would hang on input() instead.""" + import hate_crack.main as hc_main + + hf = _ntlm_hashfile(tmp_path) + calls = [] + monkeypatch.setattr(hc_main, func, lambda *a, **k: calls.append((a, k))) + monkeypatch.setattr( + "builtins.input", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompted")), + ) + assert _run_main(monkeypatch, [command, str(hf), "1000"]) == 0 + assert len(calls) == 1 + + +def test_main_bandrel_does_not_prompt_for_company(monkeypatch, tmp_path): + import hate_crack.main as hc_main + + hf = _ntlm_hashfile(tmp_path) + calls = [] + monkeypatch.setattr(hc_main, "hcatBandrel", lambda *a, **k: calls.append((a, k))) + monkeypatch.setattr( + "builtins.input", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompted")), + ) + code = _run_main(monkeypatch, ["bandrel", str(hf), "1000", "--company", "Acme"]) + assert code == 0 + assert calls[0][1]["company_name"] == "Acme" + + +def test_main_reports_bad_input_as_exit_1(monkeypatch, tmp_path): + hf = _ntlm_hashfile(tmp_path) + code = _run_main( + monkeypatch, + ["permute", str(hf), "1000", "--wordlist", str(tmp_path / "missing.txt")], + ) + assert code == 1 + + +def test_main_unknown_subcommand_exits_2(monkeypatch, tmp_path): + """#340 consistency note 2, end to end: argparse itself exits 2 for a + subcommand this version does not have, so a caller running against an + older hate_crack can tell "unsupported" from "ran and found nothing".""" + hf = _ntlm_hashfile(tmp_path) + assert _run_main(monkeypatch, ["prince", str(hf), "1000", "--bogus-flag"]) == 2 From 5a1b8cb2b26b464466a2cfb576845ddbcd5a441e Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Mon, 28 Sep 2026 19:27:50 -0400 Subject: [PATCH 2/2] fix(cli): close five silent divergences from the interactive attacks (#340) Found in review of the previous commit. Each one let a scripted run report success while doing something other than what the menu entry of the same name does -- the failure mode that matters most here, since the caller is a program that will record the run and move on. - combinator routed everything to hcatCombination, which slices to wordlists[:2]. `--wordlist a b c` therefore dropped c silently while the help promised "two or more". Now routes by count as attacks.combinator_crack does: 2 -> hcatCombination, 3 -> hcatCombinator3, else hcatCombinatorX. Adds --separator, without which the combinatorX path was unreachable. - spoonman --rule-coverage took any int. rulegen only derives rules.top{50,75,95,99}.rule, so another value named a file that never exists: the cache check missed on every run, re-doing the whole O(corpus) derivation, and capped_rules.get(N, rules_path) then fell back to the FULL rule set. Asking for a small rule set silently got the largest one, slowly. Constrained with argparse choices. - adhocmask --mask passed an unopenable path straight to hashcat, which treats it as a literal mask: a typo'd .hcmask enumerated one candidate and exited 0. The interactive path checks os.path.isfile; this now does too. - adhocmask increment bounds reached int() unvalidated, so --increment-min abc exited with a traceback rather than exit 1, and a non-positive bound was forwarded verbatim. Also adds --increment, since deriving it from the bounds made "increment over the full keyspace" -- which the menu can produce and hashcat supports -- unreachable from a script. - bandrel --company checked the string was non-blank, but hcatBandrel builds basewords from the comma-split, so "," passed and contributed nothing. Also documents the one remaining intentional divergence, in --help and the README: menu option 5 always runs hybrid passes over expanded fragments, while `fingerprint` defaults to hcatFingerprint's own default of off. Test changes from the same review: the ATTACK_COMMANDS derivation test was tautological (it compared the tuple to the expression defining it) and is replaced by an exact set equality against the registered subparsers in both directions; the 63-line combipow edge case asserted only an exit code and now asserts the dispatch too. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 5 + README.md | 14 +- hate_crack/noninteractive.py | 124 +++++++++++++-- tests/test_bandrel_company_arg.py | 35 ++++- tests/test_noninteractive_attacks.py | 216 +++++++++++++++++++++++++-- 5 files changed, 368 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7924a0..443ae660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ Dates are omitted for releases predating this file; see the git tags for exact t - **`hate_crack/noninteractive.py` is now driven by one `ATTACK_SPECS` table** instead of parallel subparser registrations and an `if`-chain dispatcher. `ATTACK_COMMANDS` is derived from it rather than hand-maintained, which closes a failure mode that grows with each subcommand added: `main.py` sets its `non_interactive` global from membership in that tuple, so a name registered as a subparser but missed in the tuple would have run the attack with every interactive prompt still live, blocking on a stdin nothing is attached to. Tests assert the derivation in both directions. ### Fixed +- **`combinator` routes by wordlist count, as the menu does.** `hcatCombination` slices to `wordlists[:2]`, so a scripted `--wordlist a b c` would have run against `a` and `b` and silently dropped `c` — while the subcommand's own help promised "two or more". Two wordlists now go to `hcatCombination`, exactly three to `hcatCombinator3`, and anything else to `hcatCombinatorX`, matching `attacks.combinator_crack`. `--separator` was added because `hcatCombinatorX` is the only one of the three that can insert one, so without it that path was unreachable. +- **`spoonman --rule-coverage` is restricted to 50, 75, 95 and 99.** Those are the only capped rule files `rulegen` derives. Any other value named a `rules.topN.rule` that never exists, which missed the cache check on *every* invocation (re-running the whole O(corpus) derivation each time) and then fell through `capped_rules.get(N, rules_path)` to the full rule set — so an operator asking for a small rule set silently got the largest one, repeatedly and expensively. +- **`adhocmask --mask` rejects a mask file that does not exist.** hashcat accepts a literal mask and a `.hcmask` path in the same slot and treats an unopenable path as a literal mask, so a typo enumerated one nonsense candidate and exited 0 — indistinguishable to a scripted caller from an attack that ran and found nothing. The interactive path already checked; this one now does too. +- **`adhocmask` validates its increment bounds and can increment over the full keyspace.** A non-numeric bound reached `int()` unguarded and exited with a traceback instead of exit 1, and a non-positive one was forwarded to hashcat verbatim. Bounds are now checked the way `attacks._prompt_length` checks them. Separately, `--increment` was added: deriving increment from the bounds alone made "increment across the full keyspace, both bounds blank" — a state the menu can produce and hashcat supports — unreachable from a script. +- **`bandrel --company` is validated against the comma-split it actually feeds.** `--company ","` passed a non-blank check while contributing no basewords at all. - **A comma-separated Bandrel company list no longer builds a mask from a leading space.** `"Acme, Globex"` split to `" Globex"`, whose first character is a space, and the per-baseword masks are derived from `name[0]` and `name[1:]` — so the second company produced `-1 ` and a mask matching nothing. Latent before now, since the prompt made multi-company input awkward; the new `--company` flag documents comma separation, which would have made it routine. Entries are stripped and blanks dropped. - **`test_commitizen_pin_is_still_coherent` no longer asserts *which* commitizen version is pinned**, only that exactly one exact `commitizen==X.Y.Z` pin exists. The literal it compared against meant every Dependabot bump of commitizen failed CI and needed a matching edit to the test before it could land (PR #322). Neither invariant the test documents -- that a pin exists so `cz commit` keeps working locally, and that neither tagging workflow calls `cz bump` -- depends on the version number, so the literal bought nothing. diff --git a/README.md b/README.md index 3333c3da..4816b08a 100644 --- a/README.md +++ b/README.md @@ -240,8 +240,12 @@ hate_crack fingerprint hashes.txt 1000 --max-expander-len 24 \ # Skip the fragment/wordlist combination step entirely hate_crack fingerprint hashes.txt 1000 --no-dictionary-wordlist -# Override the configured wordlists (combinator needs at least two) +# Override the configured wordlists (combinator needs at least two). +# Two wordlists use combinator, three use combinator3, more -- or any +# --separator -- use combinatorX, matching what the menu does. hate_crack combinator hashes.txt 1000 --wordlist first.txt second.txt +hate_crack combinator hashes.txt 1000 --wordlist a.txt b.txt c.txt +hate_crack combinator hashes.txt 1000 --wordlist a.txt b.txt --separator - hate_crack hybrid hashes.txt 1000 --wordlist rockyou.txt # Corporate masks, lengths 8-12 only @@ -265,6 +269,7 @@ hate_crack adhocmask hashes.txt 1000 --mask '?u?l?l?l?d?d' hate_crack adhocmask hashes.txt 1000 --mask masks/corporate.hcmask hate_crack adhocmask hashes.txt 1000 --mask '?a?a?a?a?a?a?a?a' \ --increment-min 4 --increment-max 8 +hate_crack adhocmask hashes.txt 1000 --mask '?a?a?a?a' --increment # N-gram candidates from a corpus hate_crack ngram hashes.txt 1000 --corpus corpus.txt --group-size 3 @@ -276,6 +281,8 @@ hate_crack combipow hashes.txt 1000 --wordlist words.txt --no-spaces # Spoonman: basewords and rules derived from a corpus of known passwords hate_crack spoonman hashes.txt 1000 --corpus previous-engagement.txt hate_crack spoonman hashes.txt 1000 --corpus previous.txt --rule-coverage 95 +# --rule-coverage accepts 50, 75, 95 or 99 -- the only capped rule files +# derived. Omit it for the full rule set. # OMEN -- requires a model trained beforehand from the interactive menu hate_crack omen hashes.txt 1000 --max-candidates 1000000 @@ -284,6 +291,11 @@ hate_crack omen hashes.txt 1000 --max-candidates 1000000 hate_crack loopback hashes.txt 1000 --rules best64.rule ``` +`fingerprint` is the one command whose bare form is not identical to its menu +entry: menu option 5 always runs the hybrid passes over expanded fragments, +while the subcommand defaults to `hcatFingerprint`'s own default of off. Pass +`--run-hybrid-on-expanded` to match the menu. + Five menu entries are still interactive-only, because they need input a single flag cannot carry: Markov brute force, Random Rules, the Rosetta attack, the LLM attack, and the Extensive Pure_Hate methodology (an orchestrator over the diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index f6db02e5..cf28eb15 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -281,6 +281,38 @@ def _run(ctx: Any, args: Any) -> int: return _run +@_validated +def _run_combinator(ctx: Any, args: Any) -> int: + """Concatenate two or more wordlists. + + Three different attack functions back this, and picking between them is + not optional: ``hcatCombination`` slices to ``wordlists[:2]``, so handing + it three would silently drop the third. The routing mirrors + ``attacks.combinator_crack`` -- two and no separator go to + ``hcatCombination``, exactly three and no separator to + ``hcatCombinator3``, and everything else to ``hcatCombinatorX``, which is + the only one of the three that can insert a separator at all. + """ + if not args.wordlists: + # No --wordlist: fall back to the configured combinator list, which is + # a pair, so hcatCombination is the right destination. + ctx.hcatCombination(*_target(ctx), wordlists=None) + return 0 + + if len(args.wordlists) < 2: + raise _BadInput("--wordlist needs at least 2 entries for a combinator attack") + wordlists = [_existing_file(ctx, w, "wordlist") for w in args.wordlists] + separator = args.separator + + if len(wordlists) == 2 and not separator: + ctx.hcatCombination(*_target(ctx), wordlists=wordlists) + elif len(wordlists) == 3 and not separator: + ctx.hcatCombinator3(*_target(ctx), wordlists) + else: + ctx.hcatCombinatorX(*_target(ctx), wordlists, separator or None) + return 0 + + # --------------------------------------------------------------------------- # Runners -- one-argument tier (#340) # --------------------------------------------------------------------------- @@ -288,10 +320,12 @@ def _run(ctx: Any, args: Any) -> int: @_validated def _run_bandrel(ctx: Any, args: Any) -> int: - company = args.company.strip() - if not company: - raise _BadInput("--company must not be blank") - ctx.hcatBandrel(*_target(ctx), company_name=company) + # Validated against the comma-split, which is what hcatBandrel actually + # consumes -- "," is non-blank but contributes no basewords at all. + names = [part.strip() for part in args.company.split(",") if part.strip()] + if not names: + raise _BadInput("--company must name at least one company") + ctx.hcatBandrel(*_target(ctx), company_name=",".join(names)) return 0 @@ -301,16 +335,51 @@ def _run_permute(ctx: Any, args: Any) -> int: return 0 +def _increment_bound(raw: str, flag: str) -> str: + """Validate one --increment-min/--increment-max value. + + Bounds stay strings so a blank one reaches the command builder blank -- + an omitted bound is hashcat's own default, not a number to invent here. + Mirrors ``attacks._prompt_length``, which re-asks until the answer is a + positive whole number or empty. + """ + raw = (raw or "").strip() + if not raw: + return "" + if not raw.isdigit() or int(raw) <= 0: + raise _BadInput(f"{flag} must be a positive whole number") + return raw + + +def _checked_mask(ctx: Any, raw: str) -> str: + """Return the mask, refusing a mask *file* that does not exist. + + hashcat takes either a literal mask or a path to a .hcmask file in the + same slot, and treats a path it cannot open as a literal mask -- so a + typo'd filename enumerates one nonsense candidate and exits 0, which a + scripted caller reads as "ran, found nothing". The interactive path checks + os.path.isfile before calling, and so does this. + """ + looks_like_a_path = os.sep in raw or raw.lower().endswith(".hcmask") + if looks_like_a_path and not os.path.isfile(raw): + raise _BadInput(f"mask file not found: {raw}") + return raw + + @_validated def _run_adhocmask(ctx: Any, args: Any) -> int: - inc_min = args.increment_min - inc_max = args.increment_max - increment = bool(inc_min or inc_max) + mask = _checked_mask(ctx, args.mask) + inc_min = _increment_bound(args.increment_min, "--increment-min") + inc_max = _increment_bound(args.increment_max, "--increment-max") + # Either bound implies increment, but --increment alone is also valid: + # it is how the interactive path reaches "increment over the full + # keyspace of the mask", which no combination of bounds can express. + increment = bool(args.increment or inc_min or inc_max) if inc_min and inc_max and int(inc_min) > int(inc_max): raise _BadInput("--increment-min must not exceed --increment-max") ctx.hcatAdHocMask( *_target(ctx), - args.mask, + mask, increment=increment, increment_min=inc_min or "", increment_max=inc_max or "", @@ -439,6 +508,24 @@ def _add_keyspace_limit(p, subject: str) -> None: ) +#: The only coverage percentages rulegen derives capped rule files for +#: (``rulegen.generate(cover=...)``). Any other value would miss the cache on +#: every run -- so the expensive derivation repeats -- and then fall back to +#: the full rule set, silently handing an operator who asked for a small rule +#: set the largest one instead. +SPOONMAN_RULE_COVERAGES = (50, 75, 95, 99) + + +def _combinator_args(p) -> None: + _add_wordlists(p, "combinator attack (at least two)") + p.add_argument( + "--separator", + default="", + help="String to insert between words. Any separator routes the attack " + "through combinatorX, the only variant that supports one.", + ) + + def _add_wordlists(p, subject: str) -> None: p.add_argument( "--wordlist", @@ -487,7 +574,10 @@ def _fingerprint_args(p) -> None: "--run-hybrid-on-expanded", action="store_true", dest="run_hybrid_on_expanded", - help="Also run hybrid passes over the expanded fragments", + help="Also run hybrid passes over the expanded fragments. NOTE: menu " + "option 5 always does this; the default here is off, matching " + "hcatFingerprint's own default, so a bare `fingerprint` runs a " + "narrower attack than the menu entry of the same name.", ) p.add_argument( "--dictionary-wordlist", @@ -549,6 +639,13 @@ def _adhocmask_args(p) -> None: required=True, help="A hashcat mask (e.g. ?u?l?l?l?d?d) or a path to a .hcmask file", ) + p.add_argument( + "--increment", + action="store_true", + dest="increment", + help="Increment the mask length. Implied by either bound below; pass " + "it alone to increment over the full keyspace of the mask.", + ) p.add_argument( "--increment-min", default="", @@ -599,8 +696,9 @@ def _spoonman_args(p) -> None: type=int, default=None, dest="rule_coverage", - help="Use the capped rule file reaching this percent of the corpus " - "(e.g. 95); omit for the full rule set.", + choices=SPOONMAN_RULE_COVERAGES, + help="Use the capped rule file reaching this percent of the corpus; " + "omit for the full rule set.", ) p.add_argument( "--baseword-cap", @@ -654,8 +752,8 @@ def _omen_args(p) -> None: AttackSpec( "combinator", "Combinator attack (concatenate two or more wordlists)", - lambda p: _add_wordlists(p, "combinator attack (at least two)"), - _wordlist_runner("hcatCombination", minimum=2), + _combinator_args, + _run_combinator, ), AttackSpec( "hybrid", diff --git a/tests/test_bandrel_company_arg.py b/tests/test_bandrel_company_arg.py index a4911a3d..318ffcf6 100644 --- a/tests/test_bandrel_company_arg.py +++ b/tests/test_bandrel_company_arg.py @@ -21,7 +21,21 @@ def stub_hashcat(monkeypatch, tmp_path): launched = [] class _Proc: + """Stands in for a Popen object. + + It carries `args` and the context-manager protocol because some call + sites reach hashcat through subprocess.run(), which builds on Popen and + touches both -- and which of those paths hcatBandrel takes depends on + module globals an earlier test may have left set. Being complete here + is what keeps this file order-independent. + """ + returncode = 0 + stdout = None + stderr = None + + def __init__(self, cmd=None): + self.args = cmd def communicate(self, *a, **k): return (b"", b"") @@ -32,12 +46,31 @@ def wait(self, *a, **k): def poll(self): return 0 + def kill(self): + return None + + # subprocess.run() and some call sites use Popen as a context manager, + # and which branch hcatBandrel takes depends on module globals an + # earlier test may have left set -- so support both shapes rather than + # being correct only in the order this file happens to run in. + def __enter__(self): + return self + + def __exit__(self, *exc): + return False + def _popen(cmd, *a, **k): launched.append(cmd) - return _Proc() + return _Proc(cmd) monkeypatch.setattr(subprocess, "Popen", _popen) monkeypatch.setattr(hc_main, "bandrelbasewords", "summer,winter") + # hcatBandrel finishes by calling pipal(), which reads the module-level + # hcatHashFile global. hate_crack.main is shared across the session, so + # whatever an earlier test left there would leak in here -- and pipal is + # a reporting step, not part of what these tests pin. + monkeypatch.setattr(hc_main, "hcatHashFile", str(tmp_path / "hashes.txt")) + monkeypatch.setattr(hc_main, "pipal", lambda *a, **k: []) return launched diff --git a/tests/test_noninteractive_attacks.py b/tests/test_noninteractive_attacks.py index b3f8a9a8..adb6106d 100644 --- a/tests/test_noninteractive_attacks.py +++ b/tests/test_noninteractive_attacks.py @@ -18,14 +18,6 @@ # -------------------------------------------------------------------------- -def test_attack_commands_is_derived_from_the_spec_table(): - """ATTACK_COMMANDS must not be a hand-maintained tuple that can drift from - the dispatcher -- main.py keys `non_interactive` off membership in it, so a - name present in one and absent from the other silently runs an attack with - the interactive prompts still live.""" - assert ni.ATTACK_COMMANDS == tuple(spec.name for spec in ni.ATTACK_SPECS) - - def test_every_spec_has_a_runner_and_unique_name(): names = [spec.name for spec in ni.ATTACK_SPECS] assert len(names) == len(set(names)), "duplicate subcommand name" @@ -34,14 +26,19 @@ def test_every_spec_has_a_runner_and_unique_name(): assert spec.help, f"{spec.name} has no help text" -def test_every_spec_registers_a_subparser(): +def test_registered_subparsers_match_attack_commands_exactly(): + """Equality, not containment, in both directions. main.py keys its + `non_interactive` global off membership in ATTACK_COMMANDS, so a name that + reached the subparsers but not the tuple would run the attack with every + interactive prompt still live; a name in the tuple with no subparser can + never be invoked.""" import argparse parser = argparse.ArgumentParser() subparsers = parser.add_subparsers(dest="command") ni.add_attack_subparsers(subparsers) - registered = set(subparsers.choices) - assert set(ni.ATTACK_COMMANDS) <= registered + assert set(subparsers.choices) == set(ni.ATTACK_COMMANDS) + assert set(ni._SPECS_BY_NAME) == set(ni.ATTACK_COMMANDS) def test_issue_340_attacks_are_all_present(): @@ -529,6 +526,7 @@ def test_combipow_accepts_exactly_63_lines(tmp_path): ctx = _spy_ctx(tmp_path) args = _parse(["combipow", ctx.hcatHashFile, "1000", "--wordlist", str(wl)]) assert ni.run_noninteractive(ctx, args) == 0 + assert [c[0] for c in ctx.calls] == ["hcatCombipow"] def test_spoonman_dispatches_with_corpus(tmp_path): @@ -788,3 +786,199 @@ def test_main_unknown_subcommand_exits_2(monkeypatch, tmp_path): older hate_crack can tell "unsupported" from "ran and found nothing".""" hf = _ntlm_hashfile(tmp_path) assert _run_main(monkeypatch, ["prince", str(hf), "1000", "--bogus-flag"]) == 2 + + +# -------------------------------------------------------------------------- +# adhocmask increment bounds -- these mirror attacks._prompt_increment, which +# enforces "a positive whole number, or blank" and keeps blanks as "" so an +# omitted bound stays hashcat's own default. +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("bad", ["abc", "-2", "0", "3.5"]) +def test_adhocmask_rejects_a_non_positive_integer_bound(tmp_path, bad): + """A bad bound must be exit 1, not a ValueError traceback out of int().""" + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "adhocmask", + ctx.hcatHashFile, + "1000", + "--mask", + "?a?a", + "--increment-min", + bad, + ] + ) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_adhocmask_increment_over_the_full_keyspace(tmp_path): + """The interactive path allows "increment? yes" with both bounds blank. + --increment reaches that state; deriving it from the bounds alone cannot.""" + ctx = _spy_ctx(tmp_path) + args = _parse( + ["adhocmask", ctx.hcatHashFile, "1000", "--mask", "?a?a", "--increment"] + ) + assert ni.run_noninteractive(ctx, args) == 0 + k = ctx.calls[0][2] + assert k["increment"] is True + assert k["increment_min"] == "" + assert k["increment_max"] == "" + + +def test_adhocmask_bounds_imply_increment_without_the_flag(tmp_path): + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "adhocmask", + ctx.hcatHashFile, + "1000", + "--mask", + "?a?a", + "--increment-max", + "6", + ] + ) + assert ni.run_noninteractive(ctx, args) == 0 + k = ctx.calls[0][2] + assert k["increment"] is True + assert k["increment_min"] == "" + assert k["increment_max"] == "6" + + +# -------------------------------------------------------------------------- +# Review findings: divergences from the interactive path that ran silently +# -------------------------------------------------------------------------- + + +def _combinator_ctx(tmp_path): + ctx = _spy_ctx(tmp_path) + for name in ("hcatCombinator3", "hcatCombinatorX"): + + def rec(n=name): + def _fn(*a, **k): + ctx.calls.append((n, a, k)) + + return _fn + + setattr(ctx, name, rec()) + return ctx + + +def test_combinator_routes_three_wordlists_to_combinator3(tmp_path): + """hcatCombination hard-slices to wordlists[:2] (main.py), so sending it + three would silently drop the third. attacks.combinator_crack routes 3 to + hcatCombinator3 and the scripted path must do the same.""" + ws = [str(_wordlist(tmp_path, f"w{i}.txt")) for i in range(3)] + ctx = _combinator_ctx(tmp_path) + args = _parse(["combinator", ctx.hcatHashFile, "1000", "--wordlist", *ws]) + assert ni.run_noninteractive(ctx, args) == 0 + name, a, k = ctx.calls[0] + assert name == "hcatCombinator3" + assert len(a[2]) == 3 + + +def test_combinator_routes_four_wordlists_to_combinatorx(tmp_path): + ws = [str(_wordlist(tmp_path, f"w{i}.txt")) for i in range(4)] + ctx = _combinator_ctx(tmp_path) + args = _parse(["combinator", ctx.hcatHashFile, "1000", "--wordlist", *ws]) + assert ni.run_noninteractive(ctx, args) == 0 + name, a, k = ctx.calls[0] + assert name == "hcatCombinatorX" + assert len(a[2]) == 4 + + +def test_combinator_separator_forces_combinatorx(tmp_path): + """attacks.combinator_crack sends any separator to hcatCombinatorX, since + neither of the other two can insert one.""" + ws = [str(_wordlist(tmp_path, f"w{i}.txt")) for i in range(2)] + ctx = _combinator_ctx(tmp_path) + args = _parse( + ["combinator", ctx.hcatHashFile, "1000", "--wordlist", *ws, "--separator", "-"] + ) + assert ni.run_noninteractive(ctx, args) == 0 + name, a, k = ctx.calls[0] + assert name == "hcatCombinatorX" + assert a[3] == "-" + + +def test_combinator_two_wordlists_still_uses_hcat_combination(tmp_path): + ws = [str(_wordlist(tmp_path, f"w{i}.txt")) for i in range(2)] + ctx = _combinator_ctx(tmp_path) + args = _parse(["combinator", ctx.hcatHashFile, "1000", "--wordlist", *ws]) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][0] == "hcatCombination" + + +@pytest.mark.parametrize("bad", ["80", "100", "1"]) +def test_spoonman_rejects_an_underived_rule_coverage(tmp_path, bad): + """rulegen only ever writes rules.top{50,75,95,99}.rule. Any other value + makes the cache check miss on every run (so the expensive derivation + repeats) and then falls back to the FULL rule set via + capped_rules.get(N, rules_path) -- an operator who asked for a small rule + set silently gets the largest one.""" + corpus = _wordlist(tmp_path, "corpus.txt") + ctx = _spy_ctx(tmp_path) + with pytest.raises(SystemExit): + _parse( + [ + "spoonman", + ctx.hcatHashFile, + "1000", + "--corpus", + str(corpus), + "--rule-coverage", + bad, + ] + ) + + +@pytest.mark.parametrize("good", [50, 75, 95, 99]) +def test_spoonman_accepts_every_derived_rule_coverage(tmp_path, good): + corpus = _wordlist(tmp_path, "corpus.txt") + ctx = _spy_ctx(tmp_path) + args = _parse( + [ + "spoonman", + ctx.hcatHashFile, + "1000", + "--corpus", + str(corpus), + "--rule-coverage", + str(good), + ] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][2]["coverage"] == good + + +def test_adhocmask_rejects_a_missing_hcmask_file(tmp_path): + """attacks.adhoc_mask_crack refuses a mask file that does not exist. Passed + through, hashcat treats the path as a LITERAL mask, enumerates one + candidate and exits 0 -- so a typo reads to a scripted caller as an attack + that ran and found nothing.""" + ctx = _spy_ctx(tmp_path) + args = _parse( + ["adhocmask", ctx.hcatHashFile, "1000", "--mask", str(tmp_path / "typo.hcmask")] + ) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_adhocmask_still_accepts_a_literal_mask_with_no_separator(tmp_path): + """The path check must not catch an ordinary mask.""" + ctx = _spy_ctx(tmp_path) + args = _parse(["adhocmask", ctx.hcatHashFile, "1000", "--mask", "?u?l?l?d"]) + assert ni.run_noninteractive(ctx, args) == 0 + + +@pytest.mark.parametrize("blank", [",", " , ", ",,,"]) +def test_bandrel_rejects_a_company_that_yields_no_basewords(tmp_path, blank): + """hcatBandrel builds its baseword list from the comma-split components, + so "," passes a non-empty check while contributing nothing.""" + ctx = _spy_ctx(tmp_path) + args = _parse(["bandrel", ctx.hcatHashFile, "1000", "--company", blank]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == []