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 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-07-24 20:01:06 -04:00
co-authored by Claude Sonnet 4.6
parent db374edda2
commit 27791457fb
2 changed files with 129 additions and 0 deletions
+48
View File
@@ -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
+81
View File
@@ -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), {})