From e11a15d4c27ad5352ffedba3cd3d4fb1ff50dd59 Mon Sep 17 00:00:00 2001 From: Justin Bollinger Date: Fri, 24 Jul 2026 20:07:48 -0400 Subject: [PATCH] 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