Merge pull request #136 from trustedsec/feature/cli-noninteractive

feat: non-interactive attack subcommands (#17)
This commit is contained in:
Justin Bollinger
2026-07-24 20:37:34 -04:00
committed by GitHub
5 changed files with 474 additions and 17 deletions
+11
View File
@@ -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.1] - 2026-07-24
### Fixed
+24
View File
@@ -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
+35 -17
View File
@@ -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
@@ -777,6 +778,7 @@ hcatGenerateRulesCount = 0
hcatPermuteCount = 0
hcatProcess: subprocess.Popen[Any] | None = None
debug_mode = False
non_interactive = False
hcatUsernamePrefix: bool = False
@@ -4075,6 +4077,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:
@@ -4746,6 +4757,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
@@ -4755,6 +4767,7 @@ def main():
# Initialize global variables
hcatHashFile = None
non_interactive = False
hcatHashType = None
hcatHashFileOrig = None
@@ -4841,6 +4854,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"
@@ -4967,7 +4981,10 @@ def main():
else:
argv = argv_temp # Fallback if subcommand not found
use_subcommand_parser = "hashview" in argv
has_attack_subcommand = any(
arg in _noninteractive.ATTACK_COMMANDS for arg in argv
)
use_subcommand_parser = "hashview" in argv or has_attack_subcommand
parser, hashview_parser = _build_parser(
include_positional=not use_subcommand_parser,
include_subcommands=use_subcommand_parser,
@@ -4976,6 +4993,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):
@@ -5274,8 +5293,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"
@@ -5297,12 +5316,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()
@@ -5348,8 +5365,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"
@@ -5372,12 +5389,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
@@ -5428,6 +5443,9 @@ def main():
else:
print("No hashes found in POT file.")
if non_interactive:
sys.exit(_noninteractive.run_noninteractive(_attack_ctx(), args))
# Display Options
try:
options = get_main_menu_options()
+145
View File
@@ -0,0 +1,145 @@
"""Non-interactive (scripted) attack entry points for hate_crack.
These helpers translate parsed argparse namespaces into calls against the
existing ``hcat*`` attack functions on the main module (passed in as ``ctx``,
the same pattern ``attacks.py`` uses). See
``docs/superpowers/specs/2026-07-24-cli-noninteractive-design.md``.
"""
import os
from typing import Any
ATTACK_COMMANDS = ("quick", "dict", "brute", "topmask")
def build_rule_chains(ctx: Any, rule_tokens: list[str] | None) -> list[str]:
"""Convert CLI ``--rules`` tokens into hashcat ``-r`` chain strings.
Each token becomes one attack pass. A token may chain multiple rule files
with ``+`` (mirroring the interactive rule selector). Filenames resolve
against ``ctx.rulesDirectory``. Returns ``[""]`` when no rules are given
(equivalent to the interactive "run without rules" choice).
Raises ``FileNotFoundError`` (with the offending filename as its argument)
if any named rule file is missing.
"""
if not rule_tokens:
return [""]
chains = []
for token in rule_tokens:
chain = ""
for name in token.split("+"):
name = name.strip()
if not name:
continue
path = os.path.join(ctx.rulesDirectory, name)
if not os.path.isfile(path):
raise FileNotFoundError(name)
chain = f"{chain} -r {path}".strip()
if not chain:
raise ValueError(f"Rule token {token!r} resolved to no rule files")
chains.append(chain)
return chains
def run_noninteractive(ctx: Any, args: Any) -> int:
"""Run a non-interactive attack. Returns a process exit code.
``ctx`` is the main module (live ``hcat*`` functions, ``rulesDirectory``,
``resolve_path``, ``hcatHashType``/``hcatHashFile`` already set by the
preprocessing block in ``main()``). ``args`` is the parsed subparser
namespace whose ``command`` selects the attack.
"""
command = args.command
if command == "quick":
wordlist = ctx.resolve_path(args.wordlist)
if not wordlist or not os.path.isfile(wordlist):
print(f"Error: wordlist not found: {args.wordlist}")
return 1
try:
chains = build_rule_chains(ctx, args.rule_files)
except (FileNotFoundError, ValueError) as exc:
print(f"Error: invalid --rules value: {exc}")
return 1
for chain in chains:
ctx.hcatQuickDictionary(
ctx.hcatHashType,
ctx.hcatHashFile,
chain,
wordlist,
attack_name="Quick Crack",
)
return 0
if command == "dict":
ctx.hcatDictionary(ctx.hcatHashType, ctx.hcatHashFile)
return 0
if command == "brute":
ctx.hcatBruteForce(
ctx.hcatHashType, ctx.hcatHashFile, args.min_len, args.max_len
)
return 0
if command == "topmask":
ctx.hcatTopMask(ctx.hcatHashType, ctx.hcatHashFile, args.target_time * 3600)
return 0
print(f"Error: unknown non-interactive command: {command}")
return 2
def add_attack_subparsers(subparsers) -> None:
"""Register the non-interactive attack subcommands on an argparse
subparsers object (the same one used for ``hashview``).
Each subcommand carries its own required ``hashfile`` + ``hashtype``
positionals plus attack-specific flags.
"""
def _add_target(p):
p.add_argument("hashfile", help="Path to hash file to crack")
p.add_argument("hashtype", help="Hashcat hash type (e.g. 1000 for NTLM)")
quick = subparsers.add_parser(
"quick", help="Non-interactive quick crack (single wordlist + optional rules)"
)
_add_target(quick)
quick.add_argument("--wordlist", required=True, help="Path to wordlist file")
quick.add_argument(
"--rules",
nargs="*",
default=[],
dest="rule_files",
metavar="RULE",
help="Rule filename(s) from the rules directory. Chain with '+' "
"(e.g. best64.rule+d3ad0ne.rule). Omit to run without rules.",
)
dictp = subparsers.add_parser(
"dict",
help="Non-interactive dictionary methodology (uses configured wordlists)",
)
_add_target(dictp)
brute = subparsers.add_parser(
"brute", help="Non-interactive brute force (mask) attack"
)
_add_target(brute)
brute.add_argument(
"--min", type=int, default=1, dest="min_len", help="Minimum length (default 1)"
)
brute.add_argument(
"--max", type=int, default=7, dest="max_len", help="Maximum length (default 7)"
)
topmask = subparsers.add_parser("topmask", help="Non-interactive top-mask attack")
_add_target(topmask)
topmask.add_argument(
"--target-time",
type=int,
default=4,
dest="target_time",
help="Target completion time in hours (default 4)",
)
+259
View File
@@ -0,0 +1,259 @@
import os
import sys
from types import SimpleNamespace
import pytest
import hate_crack.main as hc_main
from hate_crack import noninteractive as ni
def _ctx_with_rules(tmp_path, *rule_names):
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
for name in rule_names:
(rules_dir / name).write_text(":\n")
return SimpleNamespace(rulesDirectory=str(rules_dir))
def test_build_rule_chains_no_rules_returns_empty_chain(tmp_path):
ctx = _ctx_with_rules(tmp_path)
assert ni.build_rule_chains(ctx, []) == [""]
assert ni.build_rule_chains(ctx, None) == [""]
def test_build_rule_chains_single_rule(tmp_path):
ctx = _ctx_with_rules(tmp_path, "best64.rule")
chains = ni.build_rule_chains(ctx, ["best64.rule"])
expected = os.path.join(ctx.rulesDirectory, "best64.rule")
assert chains == [f"-r {expected}"]
def test_build_rule_chains_chained_token(tmp_path):
ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule")
chains = ni.build_rule_chains(ctx, ["best64.rule+d3ad0ne.rule"])
a = os.path.join(ctx.rulesDirectory, "best64.rule")
b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule")
assert chains == [f"-r {a} -r {b}"]
def test_build_rule_chains_multiple_tokens_are_separate_passes(tmp_path):
ctx = _ctx_with_rules(tmp_path, "best64.rule", "d3ad0ne.rule")
chains = ni.build_rule_chains(ctx, ["best64.rule", "d3ad0ne.rule"])
a = os.path.join(ctx.rulesDirectory, "best64.rule")
b = os.path.join(ctx.rulesDirectory, "d3ad0ne.rule")
assert chains == [f"-r {a}", f"-r {b}"]
def test_build_rule_chains_missing_file_raises(tmp_path):
ctx = _ctx_with_rules(tmp_path)
with pytest.raises(FileNotFoundError, match="nope.rule"):
ni.build_rule_chains(ctx, ["nope.rule"])
def test_build_rule_chains_empty_token_raises(tmp_path):
ctx = _ctx_with_rules(tmp_path, "best64.rule")
with pytest.raises(ValueError):
ni.build_rule_chains(ctx, ["+"])
def _spy_ctx(tmp_path, **overrides):
calls = []
def rec(name):
def _fn(*a, **k):
calls.append((name, a, k))
return _fn
ctx = SimpleNamespace(
calls=calls,
hcatHashType="1000",
hcatHashFile=str(tmp_path / "hashes.txt"),
rulesDirectory=str(tmp_path / "rules"),
resolve_path=lambda p: os.path.abspath(os.path.expanduser(p)) if p else None,
hcatQuickDictionary=rec("hcatQuickDictionary"),
hcatDictionary=rec("hcatDictionary"),
hcatBruteForce=rec("hcatBruteForce"),
hcatTopMask=rec("hcatTopMask"),
)
for k, v in overrides.items():
setattr(ctx, k, v)
return ctx
def test_dispatch_quick_calls_quick_dictionary(tmp_path):
(tmp_path / "rules").mkdir()
(tmp_path / "rules" / "best64.rule").write_text(":\n")
wl = tmp_path / "rockyou.txt"
wl.write_text("password\n")
ctx = _spy_ctx(tmp_path)
args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["best64.rule"])
code = ni.run_noninteractive(ctx, args)
assert code == 0
assert [c[0] for c in ctx.calls] == ["hcatQuickDictionary"]
name, a, k = ctx.calls[0]
assert a[0] == "1000"
assert a[1] == ctx.hcatHashFile
assert a[2] == f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}"
assert a[3] == os.path.abspath(str(wl))
def test_dispatch_quick_missing_wordlist_returns_1(tmp_path):
(tmp_path / "rules").mkdir()
ctx = _spy_ctx(tmp_path)
args = SimpleNamespace(command="quick", wordlist=str(tmp_path / "nope.txt"), rule_files=[])
assert ni.run_noninteractive(ctx, args) == 1
assert ctx.calls == []
def test_dispatch_quick_unknown_rule_returns_1(tmp_path):
(tmp_path / "rules").mkdir()
wl = tmp_path / "rockyou.txt"
wl.write_text("password\n")
ctx = _spy_ctx(tmp_path)
args = SimpleNamespace(command="quick", wordlist=str(wl), rule_files=["ghost.rule"])
assert ni.run_noninteractive(ctx, args) == 1
assert ctx.calls == []
def test_dispatch_dict_calls_dictionary(tmp_path):
ctx = _spy_ctx(tmp_path)
args = SimpleNamespace(command="dict")
assert ni.run_noninteractive(ctx, args) == 0
assert ctx.calls[0][0] == "hcatDictionary"
assert ctx.calls[0][1] == ("1000", ctx.hcatHashFile)
def test_dispatch_brute_calls_bruteforce_with_lengths(tmp_path):
ctx = _spy_ctx(tmp_path)
args = SimpleNamespace(command="brute", min_len=2, max_len=6)
assert ni.run_noninteractive(ctx, args) == 0
assert ctx.calls[0] == ("hcatBruteForce", ("1000", ctx.hcatHashFile, 2, 6), {})
def test_dispatch_topmask_converts_hours_to_seconds(tmp_path):
ctx = _spy_ctx(tmp_path)
args = SimpleNamespace(command="topmask", target_time=4)
assert ni.run_noninteractive(ctx, args) == 0
assert ctx.calls[0] == ("hcatTopMask", ("1000", ctx.hcatHashFile, 4 * 3600), {})
def test_dispatch_unknown_command_returns_2(tmp_path):
ctx = _spy_ctx(tmp_path)
args = SimpleNamespace(command="bogus")
assert ni.run_noninteractive(ctx, args) == 2
assert ctx.calls == []
def test_dispatch_quick_multiple_rules_runs_each_as_separate_pass(tmp_path):
rules = tmp_path / "rules"
rules.mkdir()
(rules / "best64.rule").write_text(":\n")
(rules / "d3ad0ne.rule").write_text(":\n")
wl = tmp_path / "rockyou.txt"
wl.write_text("password\n")
ctx = _spy_ctx(tmp_path)
args = SimpleNamespace(
command="quick", wordlist=str(wl), rule_files=["best64.rule", "d3ad0ne.rule"]
)
assert ni.run_noninteractive(ctx, args) == 0
assert len(ctx.calls) == 2
chains = [c[1][2] for c in ctx.calls]
assert chains == [
f"-r {os.path.join(ctx.rulesDirectory, 'best64.rule')}",
f"-r {os.path.join(ctx.rulesDirectory, 'd3ad0ne.rule')}",
]
def _run_main(monkeypatch, argv):
monkeypatch.setattr(sys, "argv", ["hate_crack.py"] + argv)
# main()'s pre-dispatch potfile-recovery step shells out to the real hashcat
# binary, which is absent in CI. Stub it — these tests exercise dispatch
# routing and prompt suppression, not potfile recovery.
monkeypatch.setattr(hc_main, "_run_hashcat_show", lambda *a, **k: None)
with pytest.raises(SystemExit) as excinfo:
hc_main.main()
return excinfo.value.code
def _make_ntlm_hashfile(tmp_path):
# Bare 32-hex NTLM hash: exercises the non-pwdump preprocessing branch.
hf = tmp_path / "hashes.txt"
hf.write_text("aad3b435b51404eeaad3b435b51404ee\n")
return hf
def test_main_quick_dispatches_without_prompting(monkeypatch, tmp_path):
hf = _make_ntlm_hashfile(tmp_path)
wl = tmp_path / "rockyou.txt"
wl.write_text("password\n")
calls = []
monkeypatch.setattr(
hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append((a, k))
)
monkeypatch.setattr(
"builtins.input",
lambda *a, **k: (_ for _ in ()).throw(AssertionError("prompted")),
)
code = _run_main(monkeypatch, ["quick", str(hf), "1000", "--wordlist", str(wl)])
assert code == 0
assert len(calls) == 1
def test_main_brute_dispatches(monkeypatch, tmp_path):
hf = _make_ntlm_hashfile(tmp_path)
calls = []
monkeypatch.setattr(hc_main, "hcatBruteForce", lambda *a, **k: calls.append(a))
code = _run_main(
monkeypatch, ["brute", str(hf), "1000", "--min", "1", "--max", "8"]
)
assert code == 0
assert calls[0][2] == 1 and calls[0][3] == 8
def test_main_quick_bad_hashtype_errors(monkeypatch, tmp_path):
hf = _make_ntlm_hashfile(tmp_path)
wl = tmp_path / "w.txt"
wl.write_text("x\n")
code = _run_main(
monkeypatch, ["quick", str(hf), "notanumber", "--wordlist", str(wl)]
)
assert code != 0
def test_main_quick_with_rules_dispatches(monkeypatch, tmp_path):
hf = _make_ntlm_hashfile(tmp_path)
wl = tmp_path / "rockyou.txt"
wl.write_text("password\n")
rules_dir = tmp_path / "rules"
rules_dir.mkdir()
(rules_dir / "best64.rule").write_text(":\n")
monkeypatch.setattr(hc_main, "rulesDirectory", str(rules_dir))
calls = []
monkeypatch.setattr(
hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a)
)
code = _run_main(
monkeypatch,
["quick", str(hf), "1000", "--wordlist", str(wl), "--rules", "best64.rule"],
)
assert code == 0
assert len(calls) == 1
# the rule chain (4th positional arg) must reference best64.rule
assert "best64.rule" in calls[0][2]
def test_main_debug_flag_before_subcommand(monkeypatch, tmp_path):
hf = _make_ntlm_hashfile(tmp_path)
wl = tmp_path / "w.txt"
wl.write_text("x\n")
calls = []
monkeypatch.setattr(
hc_main, "hcatQuickDictionary", lambda *a, **k: calls.append(a)
)
code = _run_main(
monkeypatch,
["--debug", "quick", str(hf), "1000", "--wordlist", str(wl)],
)
assert code == 0
assert len(calls) == 1