From d2e45afb4a87ffbd7946710775f481ff0635538a Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 19:55:43 -0400 Subject: [PATCH 1/8] feat: add rule-chain builder for non-interactive mode Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/noninteractive.py | 38 +++++++++++++++++++++++++++++ tests/test_noninteractive.py | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 hate_crack/noninteractive.py create mode 100644 tests/test_noninteractive.py diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py new file mode 100644 index 0000000..154e615 --- /dev/null +++ b/hate_crack/noninteractive.py @@ -0,0 +1,38 @@ +"""Non-interactive (scripted) attack entry points for hate_crack. + +These helpers translate parsed argparse namespaces into calls against the +existing ``hcat*`` attack functions on the main module (passed in as ``ctx``, +the same pattern ``attacks.py`` uses). See +``docs/superpowers/specs/2026-07-24-cli-noninteractive-design.md``. +""" + +import os +from typing import Any + + +def build_rule_chains(ctx: Any, rule_tokens): + """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. + + Each token becomes one attack pass. A token may chain multiple rule files + with ``+`` (mirroring the interactive rule selector). Filenames resolve + against ``ctx.rulesDirectory``. Returns ``[""]`` when no rules are given + (equivalent to the interactive "run without rules" choice). + + Raises ``FileNotFoundError`` (with the offending filename as its argument) + if any named rule file is missing. + """ + if not rule_tokens: + return [""] + chains = [] + for token in rule_tokens: + chain = "" + for name in token.split("+"): + name = name.strip() + if not name: + continue + path = os.path.join(ctx.rulesDirectory, name) + if not os.path.isfile(path): + raise FileNotFoundError(name) + chain = f"{chain} -r {path}".strip() + chains.append(chain) + return chains diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py new file mode 100644 index 0000000..194e801 --- /dev/null +++ b/tests/test_noninteractive.py @@ -0,0 +1,47 @@ +import os +from types import SimpleNamespace + +import pytest + +from hate_crack import noninteractive as ni + + +def _ctx_with_rules(tmp_path, *rule_names): + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + for name in rule_names: + (rules_dir / name).write_text(":\n") + return SimpleNamespace(rulesDirectory=str(rules_dir)) + + +def test_build_rule_chains_no_rules_returns_empty_chain(tmp_path): + ctx = _ctx_with_rules(tmp_path) + assert ni.build_rule_chains(ctx, []) == [""] + assert ni.build_rule_chains(ctx, None) == [""] + + +def test_build_rule_chains_single_rule(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule"]) + expected = os.path.join(ctx.rulesDirectory, "best64.rule") + assert chains == [f"-r {expected}"] + + +def test_build_rule_chains_chained_token(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule+d3ad0ne.rule"]) + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a} -r {b}"] + + +def test_build_rule_chains_multiple_tokens_are_separate_passes(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") + chains = ni.build_rule_chains(ctx, ["best64.rule", "d3ad0ne.rule"]) + assert len(chains) == 2 + + +def test_build_rule_chains_missing_file_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path) + with pytest.raises(FileNotFoundError): + ni.build_rule_chains(ctx, ["nope.rule"]) From db374edda29d8aa860831791ae9130a6418d2f53 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 19:59:22 -0400 Subject: [PATCH 2/8] refactor: harden rule-chain builder (types, empty-token guard, stronger tests) Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/noninteractive.py | 4 +++- tests/test_noninteractive.py | 12 ++++++++++-- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index 154e615..9790fdb 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -10,7 +10,7 @@ import os from typing import Any -def build_rule_chains(ctx: Any, rule_tokens): +def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. Each token becomes one attack pass. A token may chain multiple rule files @@ -34,5 +34,7 @@ def build_rule_chains(ctx: Any, rule_tokens): if not os.path.isfile(path): raise FileNotFoundError(name) chain = f"{chain} -r {path}".strip() + if not chain: + raise ValueError(f"Rule token {token!r} resolved to no rule files") chains.append(chain) return chains diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 194e801..3fd50e8 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -38,10 +38,18 @@ def test_build_rule_chains_chained_token(tmp_path): def test_build_rule_chains_multiple_tokens_are_separate_passes(tmp_path): ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule") chains = ni.build_rule_chains(ctx, ["best64.rule", "d3ad0ne.rule"]) - assert len(chains) == 2 + a = os.path.join(ctx.rulesDirectory, "best64.rule") + b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule") + assert chains == [f"-r {a}", f"-r {b}"] def test_build_rule_chains_missing_file_raises(tmp_path): ctx = _ctx_with_rules(tmp_path) - with pytest.raises(FileNotFoundError): + with pytest.raises(FileNotFoundError, match="nope.rule"): ni.build_rule_chains(ctx, ["nope.rule"]) + + +def test_build_rule_chains_empty_token_raises(tmp_path): + ctx = _ctx_with_rules(tmp_path, "best64.rule") + with pytest.raises(ValueError): + ni.build_rule_chains(ctx, ["+"]) From 27791457fbb43bbc65f76d6e7ae3b317ee5488dc Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:01:06 -0400 Subject: [PATCH 3/8] feat: add non-interactive attack dispatcher Appends `run_noninteractive(ctx, args)` to noninteractive.py, which dispatches quick/dict/brute/topmask commands to the appropriate hcat* function. Returns 0 on success, 1 on bad inputs, 2 on unknown command. Includes 6 new unit tests (12 total in file), all passing. Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/noninteractive.py | 48 +++++++++++++++++++++ tests/test_noninteractive.py | 81 ++++++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+) diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index 9790fdb..f473f5c 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -38,3 +38,51 @@ def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: raise ValueError(f"Rule token {token!r} resolved to no rule files") chains.append(chain) return chains + + +def run_noninteractive(ctx: Any, args: Any) -> int: + """Run a non-interactive attack. Returns a process exit code. + + ``ctx`` is the main module (live ``hcat*`` functions, ``rulesDirectory``, + ``resolve_path``, ``hcatHashType``/``hcatHashFile`` already set by the + preprocessing block in ``main()``). ``args`` is the parsed subparser + namespace whose ``command`` selects the attack. + """ + command = args.command + + if command == "quick": + wordlist = ctx.resolve_path(args.wordlist) + if not wordlist or not os.path.isfile(wordlist): + print(f"Error: wordlist not found: {args.wordlist}") + return 1 + try: + chains = build_rule_chains(ctx, args.rules) + except (FileNotFoundError, ValueError) as exc: + print(f"Error: invalid --rules value: {exc}") + return 1 + for chain in chains: + ctx.hcatQuickDictionary( + ctx.hcatHashType, + ctx.hcatHashFile, + chain, + wordlist, + attack_name="Quick Crack", + ) + return 0 + + if command == "dict": + ctx.hcatDictionary(ctx.hcatHashType, ctx.hcatHashFile) + return 0 + + if command == "brute": + ctx.hcatBruteForce( + ctx.hcatHashType, ctx.hcatHashFile, args.min_len, args.max_len + ) + return 0 + + if command == "topmask": + ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, args.target_time * 3600) + return 0 + + print(f"Error: unknown non-interactive command: {command}") + return 2 diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 3fd50e8..e06283f 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -53,3 +53,84 @@ def test_build_rule_chains_empty_token_raises(tmp_path): ctx = _ctx_with_rules(tmp_path, "best64.rule") with pytest.raises(ValueError): ni.build_rule_chains(ctx, ["+"]) + + +def _spy_ctx(tmp_path, **overrides): + calls = [] + + def rec(name): + def _fn(*a, **k): + calls.append((name, a, k)) + return _fn + + ctx = SimpleNamespace( + calls=calls, + hcatHashType="1000", + hcatHashFile=str(tmp_path / "hashes.txt"), + rulesDirectory=str(tmp_path / "rules"), + resolve_path=lambda p: os.path.abspath(os.path.expanduser(p)) if p else None, + hcatQuickDictionary=rec("hcatQuickDictionary"), + hcatDictionary=rec("hcatDictionary"), + hcatBruteForce=rec("hcatBruteForce"), + hcatTopMask=rec("hcatTopMask"), + ) + for k, v in overrides.items(): + setattr(ctx, k, v) + return ctx + + +def test_dispatch_quick_calls_quick_dictionary(tmp_path): + (tmp_path / "rules").mkdir() + (tmp_path / "rules" / "best64.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["best64.rule"]) + code = ni.run_noninteractive(ctx, args) + assert code == 0 + assert [c[0] for c in ctx.calls] == ["hcatQuickDictionary"] + name, a, k = ctx.calls[0] + assert a[0] == "1000" + assert a[1] == ctx.hcatHashFile + assert a[2] == f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}" + assert a[3] == os.path.abspath(str(wl)) + + +def test_dispatch_quick_missing_wordlist_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rules=[]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_quick_unknown_rule_returns_1(tmp_path): + (tmp_path / "rules").mkdir() + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["ghost.rule"]) + assert ni.run_noninteractive(ctx, args) == 1 + assert ctx.calls == [] + + +def test_dispatch_dict_calls_dictionary(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="dict") + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0][0] == "hcatDictionary" + assert ctx.calls[0][1] == ("1000", ctx.hcatHashFile) + + +def test_dispatch_brute_calls_bruteforce_with_lengths(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="brute", min_len=2, max_len=6) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatBruteForce", ("1000", ctx.hcatHashFile, 2, 6), {}) + + +def test_dispatch_topmask_converts_hours_to_seconds(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="topmask", target_time=4) + assert ni.run_noninteractive(ctx, args) == 0 + assert ctx.calls[0] == ("hcatTopMask", ("1000", ctx.hcatHashFile, 4 * 3600), {}) From 1f0dcb4589cb0ee6dedd4272f9cba0247b2cf876 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:03:50 -0400 Subject: [PATCH 4/8] test: cover unknown-command and multi-rule dispatch paths Co-Authored-By: Claude Sonnet 4.6 --- tests/test_noninteractive.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index e06283f..72f1436 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -134,3 +134,30 @@ def test_dispatch_topmask_converts_hours_to_seconds(tmp_path): args = SimpleNamespace(command="topmask", target_time=4) assert ni.run_noninteractive(ctx, args) == 0 assert ctx.calls[0] == ("hcatTopMask", ("1000", ctx.hcatHashFile, 4 * 3600), {}) + + +def test_dispatch_unknown_command_returns_2(tmp_path): + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace(command="bogus") + assert ni.run_noninteractive(ctx, args) == 2 + assert ctx.calls == [] + + +def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): + rules = tmp_path / "rules" + rules.mkdir() + (rules / "best64.rule").write_text(":\n") + (rules / "d3ad0ne.rule").write_text(":\n") + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + ctx = _spy_ctx(tmp_path) + args = SimpleNamespace( + command="quick", wordlist=str(wl), rules=["best64.rule", "d3ad0ne.rule"] + ) + assert ni.run_noninteractive(ctx, args) == 0 + assert len(ctx.calls) == 2 + chains = [c[1][2] for c in ctx.calls] + assert chains == [ + f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}", + f"-r {os.path.join(ctx.rulesDirectory, 'd3ad0ne.rule')}", + ] From e11a15d4c27ad5352ffedba3cd3d4fb1ff50dd59 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:07:48 -0400 Subject: [PATCH 5/8] feat: wire non-interactive attack subcommands into the CLI (#17) Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/main.py | 40 ++++++++++++++++++------- hate_crack/noninteractive.py | 57 ++++++++++++++++++++++++++++++++++++ tests/test_noninteractive.py | 54 ++++++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 11 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index f6dafd0..2a14025 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -74,6 +74,7 @@ from hate_crack.cli import ( # noqa: E402 ) from hate_crack import attacks as _attacks # noqa: E402 from hate_crack import llm # noqa: E402 +from hate_crack import noninteractive as _noninteractive # noqa: E402 from hate_crack.progress import spinner # noqa: E402 from hate_crack.menu import interactive_menu # noqa: E402 from hate_crack.username_detect import detect_username_hash_format # noqa: E402 @@ -760,6 +761,7 @@ hcatGenerateRulesCount = 0 hcatPermuteCount = 0 hcatProcess: subprocess.Popen[Any] | None = None debug_mode = False +non_interactive = False hcatUsernamePrefix: bool = False @@ -4058,6 +4060,15 @@ def hashview_api(): print(f"\nError connecting to Hashview: {str(e)}") +def _auto_input(prompt, default=""): + """input() wrapper that returns the default without prompting when running + in non-interactive (scripted) mode. In interactive mode this is identical + to ``input(prompt) or default``.""" + if non_interactive: + return default + return input(prompt) or default + + def _attack_ctx(): ctx = sys.modules.get(__name__) if ctx is None: @@ -4729,6 +4740,7 @@ def main(): global hcatHashFileOrig global lmHashesFound global debug_mode + global non_interactive global hashview_url, hashview_api_key global hcatPath, hcatBin, hcatWordlists, hcatOptimizedWordlists, rulesDirectory global pipalPath, maxruntime, bandrelbasewords @@ -4738,6 +4750,7 @@ def main(): # Initialize global variables hcatHashFile = None + non_interactive = False hcatHashType = None hcatHashFileOrig = None @@ -4824,6 +4837,7 @@ def main(): return parser, hashview_parser subparsers = parser.add_subparsers(dest="command") + _noninteractive.add_attack_subparsers(subparsers) hashview_parser = subparsers.add_parser( "hashview", help="Hashview menu actions" @@ -4950,7 +4964,8 @@ def main(): else: argv = argv_temp # Fallback if subcommand not found - use_subcommand_parser = "hashview" in argv + has_attack_subcommand = bool(argv) and argv[0] in _noninteractive.ATTACK_COMMANDS + use_subcommand_parser = "hashview" in argv or has_attack_subcommand parser, hashview_parser = _build_parser( include_positional=not use_subcommand_parser, include_subcommands=use_subcommand_parser, @@ -4959,6 +4974,8 @@ def main(): global debug_mode debug_mode = args.debug + if getattr(args, "command", None) in _noninteractive.ATTACK_COMMANDS: + non_interactive = True # CLI flags override config file. if getattr(args, "no_potfile_path", False): @@ -5257,8 +5274,8 @@ def main(): f"Detected {computer_count} computer account(s)" " (usernames ending with $)." ) - filter_choice = ( - input("Would you like to ignore computer accounts? (Y) ") or "Y" + filter_choice = _auto_input( + "Would you like to ignore computer accounts? (Y) ", "Y" ) if filter_choice.upper() == "Y": filtered_path = f"{hcatHashFile}.filtered" @@ -5280,12 +5297,10 @@ def main(): ) ) or (lineCount(hcatHashFile + ".lm") > 1): lmHashesFound = True - lmChoice = ( - input( - "LM hashes identified. Would you like to brute force" - " the LM hashes first? (Y) " - ) - or "Y" + lmChoice = _auto_input( + "LM hashes identified. Would you like to brute force" + " the LM hashes first? (Y) ", + "Y", ) if lmChoice.upper() == "Y": hcatLMtoNT() @@ -5331,8 +5346,8 @@ def main(): f"Detected {computer_count} computer account(s)" " (usernames ending with $)." ) - filter_choice = ( - input("Would you like to ignore computer accounts? (Y) ") or "Y" + filter_choice = _auto_input( + "Would you like to ignore computer accounts? (Y) ", "Y" ) if filter_choice.upper() == "Y": filtered_path = f"{hcatHashFile}.filtered" @@ -5411,6 +5426,9 @@ def main(): else: print("No hashes found in POT file.") + if non_interactive: + sys.exit(_noninteractive.run_noninteractive(_attack_ctx(), args)) + # Display Options try: options = get_main_menu_options() diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index f473f5c..4c732e4 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -86,3 +86,60 @@ def run_noninteractive(ctx: Any, args: Any) -> int: print(f"Error: unknown non-interactive command: {command}") return 2 + + +ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") + + +def add_attack_subparsers(subparsers) -> None: + """Register the non-interactive attack subcommands on an argparse + subparsers object (the same one used for ``hashview``). + + Each subcommand carries its own required ``hashfile`` + ``hashtype`` + positionals plus attack-specific flags. + """ + + def _add_target(p): + p.add_argument("hashfile", help="Path to hash file to crack") + p.add_argument("hashtype", help="Hashcat hash type (e.g. 1000 for NTLM)") + + quick = subparsers.add_parser( + "quick", help="Non-interactive quick crack (single wordlist + optional rules)" + ) + _add_target(quick) + quick.add_argument("--wordlist", required=True, help="Path to wordlist file") + quick.add_argument( + "--rules", + nargs="*", + default=[], + metavar="RULE", + help="Rule filename(s) from the rules directory. Chain with '+' " + "(e.g. best64.rule+d3ad0ne.rule). Omit to run without rules.", + ) + + dictp = subparsers.add_parser( + "dict", + help="Non-interactive dictionary methodology (uses configured wordlists)", + ) + _add_target(dictp) + + brute = subparsers.add_parser( + "brute", help="Non-interactive brute force (mask) attack" + ) + _add_target(brute) + brute.add_argument( + "--min", type=int, default=1, dest="min_len", help="Minimum length (default 1)" + ) + brute.add_argument( + "--max", type=int, default=7, dest="max_len", help="Maximum length (default 7)" + ) + + topmask = subparsers.add_parser("topmask", help="Non-interactive top-mask attack") + _add_target(topmask) + topmask.add_argument( + "--target-time", + type=int, + default=4, + dest="target_time", + help="Target completion time in hours (default 4)", + ) diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 72f1436..b25d5bf 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -1,8 +1,10 @@ import os +import sys from types import SimpleNamespace import pytest +import hate_crack.main as hc_main from hate_crack import noninteractive as ni @@ -161,3 +163,55 @@ def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}", f"-r {os.path.join(ctx.rulesDirectory, 'd3ad0ne.rule')}", ] + + +def _run_main(monkeypatch, argv): + monkeypatch.setattr(sys, "argv", ["hate_crack.py"] + argv) + with pytest.raises(SystemExit) as excinfo: + hc_main.main() + return excinfo.value.code + + +def _make_ntlm_hashfile(tmp_path): + # Bare 32-hex NTLM hash: exercises the non-pwdump preprocessing branch. + hf = tmp_path / "hashes.txt" + hf.write_text("aad3b435b51404eeaad3b435b51404ee\n") + return hf + + +def test_main_quick_dispatches_without_prompting(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append((a, k)) + ) + monkeypatch.setattr( + "builtins.input", + lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompted")), + ) + code = _run_main(monkeypatch, ["quick", str(hf), "1000", "--wordlist", str(wl)]) + assert code == 0 + assert len(calls) == 1 + + +def test_main_brute_dispatches(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + calls = [] + monkeypatch.setattr(hc_main, "hcatBruteForce", lambda *a, **k: calls.append(a)) + code = _run_main( + monkeypatch, ["brute", str(hf), "1000", "--min", "1", "--max", "8"] + ) + assert code == 0 + assert calls[0][2] == 1 and calls[0][3] == 8 + + +def test_main_quick_bad_hashtype_errors(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "w.txt" + wl.write_text("x\n") + code = _run_main( + monkeypatch, ["quick", str(hf), "notanumber", "--wordlist", str(wl)] + ) + assert code != 0 From 1a8bacfbc7725ba1543ff660a5506c9191018c3e Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:15:47 -0400 Subject: [PATCH 6/8] fix: resolve --rules dest collision, non-blocking dedup prompt, subcommand detection (#17) - Change quick subparser --rules to dest=rule_files to prevent collision with the top-level --rules=store_true Hashmob download flag - Update run_noninteractive to read args.rule_files instead of args.rules - Move ATTACK_COMMANDS above run_noninteractive (Fix 5) - Fix has_attack_subcommand to scan all argv elements (supports leading global flags like --debug) - Replace raw input() dedup prompt with _auto_input() so non-interactive runs don't block - Update existing quick dispatch tests to use rule_files= namespace key - Add test_main_quick_with_rules_dispatches: proves --rules routes to crack, not download - Add test_main_debug_flag_before_subcommand: proves leading global flags don't break routing Co-Authored-By: Claude Sonnet 4.6 --- hate_crack/main.py | 14 +++++------ hate_crack/noninteractive.py | 8 +++---- tests/test_noninteractive.py | 46 ++++++++++++++++++++++++++++++++---- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/hate_crack/main.py b/hate_crack/main.py index 2a14025..0dda836 100755 --- a/hate_crack/main.py +++ b/hate_crack/main.py @@ -4964,7 +4964,9 @@ def main(): else: argv = argv_temp # Fallback if subcommand not found - has_attack_subcommand = bool(argv) and argv[0] in _noninteractive.ATTACK_COMMANDS + has_attack_subcommand = any( + arg in _noninteractive.ATTACK_COMMANDS for arg in argv + ) use_subcommand_parser = "hashview" in argv or has_attack_subcommand parser, hashview_parser = _build_parser( include_positional=not use_subcommand_parser, @@ -5370,12 +5372,10 @@ def main(): f"Detected {duplicates} duplicate account(s) out of" f" {total} total NetNTLM hashes." ) - dedup_choice = ( - input( - "Would you like to ignore duplicate accounts" - " (keep first occurrence only)? (Y) " - ) - or "Y" + dedup_choice = _auto_input( + "Would you like to ignore duplicate accounts" + " (keep first occurrence only)? (Y) ", + "Y", ) if dedup_choice.upper() == "Y": hcatHashFileOrig = hcatHashFile diff --git a/hate_crack/noninteractive.py b/hate_crack/noninteractive.py index 4c732e4..18a23bb 100644 --- a/hate_crack/noninteractive.py +++ b/hate_crack/noninteractive.py @@ -9,6 +9,8 @@ the same pattern ``attacks.py`` uses). See import os from typing import Any +ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") + def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]: """Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings. @@ -56,7 +58,7 @@ def run_noninteractive(ctx: Any, args: Any) -> int: print(f"Error: wordlist not found: {args.wordlist}") return 1 try: - chains = build_rule_chains(ctx, args.rules) + chains = build_rule_chains(ctx, args.rule_files) except (FileNotFoundError, ValueError) as exc: print(f"Error: invalid --rules value: {exc}") return 1 @@ -88,9 +90,6 @@ def run_noninteractive(ctx: Any, args: Any) -> int: return 2 -ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask") - - def add_attack_subparsers(subparsers) -> None: """Register the non-interactive attack subcommands on an argparse subparsers object (the same one used for ``hashview``). @@ -112,6 +111,7 @@ def add_attack_subparsers(subparsers) -> None: "--rules", nargs="*", default=[], + dest="rule_files", metavar="RULE", help="Rule filename(s) from the rules directory. Chain with '+' " "(e.g. best64.rule+d3ad0ne.rule). Omit to run without rules.", diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index b25d5bf..9386df3 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -87,7 +87,7 @@ def test_dispatch_quick_calls_quick_dictionary(tmp_path): wl = tmp_path / "rockyou.txt" wl.write_text("password\n") ctx = _spy_ctx(tmp_path) - args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["best64.rule"]) + args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["best64.rule"]) code = ni.run_noninteractive(ctx, args) assert code == 0 assert [c[0] for c in ctx.calls] == ["hcatQuickDictionary"] @@ -101,7 +101,7 @@ def test_dispatch_quick_calls_quick_dictionary(tmp_path): def test_dispatch_quick_missing_wordlist_returns_1(tmp_path): (tmp_path / "rules").mkdir() ctx = _spy_ctx(tmp_path) - args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rules=[]) + args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rule_files=[]) assert ni.run_noninteractive(ctx, args) == 1 assert ctx.calls == [] @@ -111,7 +111,7 @@ def test_dispatch_quick_unknown_rule_returns_1(tmp_path): wl = tmp_path / "rockyou.txt" wl.write_text("password\n") ctx = _spy_ctx(tmp_path) - args = SimpleNamespace(command="quick", wordlist=str(wl), rules=["ghost.rule"]) + args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["ghost.rule"]) assert ni.run_noninteractive(ctx, args) == 1 assert ctx.calls == [] @@ -154,7 +154,7 @@ def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): wl.write_text("password\n") ctx = _spy_ctx(tmp_path) args = SimpleNamespace( - command="quick", wordlist=str(wl), rules=["best64.rule", "d3ad0ne.rule"] + command="quick", wordlist=str(wl), rule_files=["best64.rule", "d3ad0ne.rule"] ) assert ni.run_noninteractive(ctx, args) == 0 assert len(ctx.calls) == 2 @@ -215,3 +215,41 @@ def test_main_quick_bad_hashtype_errors(monkeypatch, tmp_path): monkeypatch, ["quick", str(hf), "notanumber", "--wordlist", str(wl)] ) assert code != 0 + + +def test_main_quick_with_rules_dispatches(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "rockyou.txt" + wl.write_text("password\n") + rules_dir = tmp_path / "rules" + rules_dir.mkdir() + (rules_dir / "best64.rule").write_text(":\n") + monkeypatch.setattr(hc_main, "rulesDirectory", str(rules_dir)) + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a) + ) + code = _run_main( + monkeypatch, + ["quick", str(hf), "1000", "--wordlist", str(wl), "--rules", "best64.rule"], + ) + assert code == 0 + assert len(calls) == 1 + # the rule chain (4th positional arg) must reference best64.rule + assert "best64.rule" in calls[0][2] + + +def test_main_debug_flag_before_subcommand(monkeypatch, tmp_path): + hf = _make_ntlm_hashfile(tmp_path) + wl = tmp_path / "w.txt" + wl.write_text("x\n") + calls = [] + monkeypatch.setattr( + hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a) + ) + code = _run_main( + monkeypatch, + ["--debug", "quick", str(hf), "1000", "--wordlist", str(wl)], + ) + assert code == 0 + assert len(calls) == 1 From 289da256094bfc727fcb7dc6422d7cf4b1c82b50 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:19:43 -0400 Subject: [PATCH 7/8] docs: document non-interactive attack subcommands --- CHANGELOG.md | 11 +++++++++++ README.md | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 185e62d..5ac4799 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Dates are omitted for releases predating this file; see the git tags for exact timing. +## [2.14.0] - 2026-07-24 + +### Added + +- **Non-interactive attack subcommands** for scripting (issue #17). Launch a + single attack without the menu: `quick` (wordlist + optional `--rules`), + `dict` (configured-wordlist methodology), `brute` (`--min`/`--max`), and + `topmask` (`--target-time`). Preprocessing prompts auto-accept their + defaults, and the process returns a clean exit code (0 on success, non-zero + on a bad hash file, hash type, wordlist, or rule name). + ## [2.13.0] - 2026-07-24 ### Added diff --git a/README.md b/README.md index 32e2738..792fa5f 100644 --- a/README.md +++ b/README.md @@ -159,6 +159,30 @@ You can also use Python directly: python hate_crack.py ``` +### Non-interactive / scripted usage + +For automation you can launch a single attack directly, bypassing the menu. The attack name is the first argument, followed by the hash file and hashcat hash type. Preprocessing prompts (computer-account filtering, LM-first brute force, duplicate-account dedup) auto-accept their defaults in this mode. The process exits `0` on success and non-zero on error (missing hash file, non-numeric hash type, missing wordlist, or an unknown rule filename). + +```bash +# Quick crack: one wordlist + optional rule(s) from the rules directory +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule + +# Chain two rules in a single run +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule+d3ad0ne.rule + +# Run two rules as two separate passes +hate_crack quick hashes.txt 1000 --wordlist rockyou.txt --rules best64.rule d3ad0ne.rule + +# Canned dictionary methodology (uses your configured wordlists) +hate_crack dict hashes.txt 1000 + +# Brute force lengths 1-8 +hate_crack brute hashes.txt 1000 --min 1 --max 8 + +# Top-mask attack targeting ~4 hours +hate_crack topmask hashes.txt 1000 --target-time 4 +``` + ------------------------------------------------------------------- ## Troubleshooting From dde6f92d9665f16621e395dae880901e92efaf87 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:35:11 -0400 Subject: [PATCH 8/8] test: stub hashcat potfile-recovery call in non-interactive integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-dispatch 'check POT file' step in main() shells out to the real hashcat binary, which is absent in CI, causing the four test_main_* tests to fail with FileNotFoundError. Stub _run_hashcat_show in the shared _run_main helper — these tests verify dispatch routing and prompt suppression, not potfile recovery. Co-Authored-By: Claude --- tests/test_noninteractive.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py index 9386df3..07aba49 100644 --- a/tests/test_noninteractive.py +++ b/tests/test_noninteractive.py @@ -167,6 +167,10 @@ def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path): def _run_main(monkeypatch, argv): monkeypatch.setattr(sys, "argv", ["hate_crack.py"] + argv) + # main()'s pre-dispatch potfile-recovery step shells out to the real hashcat + # binary, which is absent in CI. Stub it — these tests exercise dispatch + # routing and prompt suppression, not potfile recovery. + monkeypatch.setattr(hc_main, "_run_hashcat_show", lambda *a, **k: None) with pytest.raises(SystemExit) as excinfo: hc_main.main() return excinfo.value.code