mirror of
https://github.com/Z4nzu/hackingtool.git
synced 2026-08-20 21:30:09 +08:00
Squashed rework of hackingtool from a tool launcher into an AI-guided operator
console for authorized security testing. 93 commits collapsed into this one;
the pre-rework tree is tagged v2.0.0.
CATALOG & ENGINE
- Data-driven YAML catalog (21 categories, 215 live tools + 59 archived) with a
registry/overlay loader and a fixed 66-tag taxonomy (63 in use). Adding a tool
is one YAML entry, not edits across the codebase.
- Engine honesty: real exit codes, truthful install success/failure, reuse-first
skip, EOF-safe prompts, command audit logging.
- Safe installs: sha256-required safe-fetch (killed `curl | bash` in feroxbuster,
Caido and Sliver), list-form subprocess only, no forced sudo.
AI LAYER (bring-your-own-key or local model; degrades offline, never fabricates)
- AI1 intent -> tools; AI2 tool+goal -> command, curated-first with a grounded
fallback; AI3 findings summary and engagement report; AI4 per-finding impact
and remediation. Prompt-injection hardened per OWASP LLM01.
- /goal plans an objective and runs it one step at a time, showing every command
before it runs, with a plan.json + run.log audit trail.
/find TOOL DISCOVERY (this branch's headline feature)
- Suggests real GitHub projects when the catalog has no tool for a need.
Deterministic: zero model calls, structured API fields only, suggest-only —
it never clones, installs or runs anything.
- A charter filter refuses destructive/DoS/jamming/mass-targeting/evasion asks
before any network I/O, while a defensive-intent guard keeps blue-team and
DFIR phrasing ("detect a SYN flood in a pcap") from being false-refused.
- Query rewriting proved to be the dominant quality lever (the first design
measured 29% precision with no results on 5 of 8 needs): a curated 41-row
intent table maps plain English to canonical jargon plus a GitHub topic, and
a two-arm search unions topic coverage with jargon precision.
- Explainable additive ranking: log-flattened stars, license/age/language,
trusted-author bonus derived from owners we already ship, docs-repo demotion
by name, staleness as a soft demotion rather than a filter (a hard cutoff
would delete THC-Hydan and John the Ripper), and a relevance term weighting
curated topics above free-text description.
- Optional no-scope GitHub token purely as a rate-limit lever (10 -> 30 req/min);
it reaches only an Authorization header, never a cache key, log or output.
- `[a]` saves a pick to ~/.hackingtool/found.yaml as a structurally inert entry,
and the loader strips executable keys from user catalogs at read time so a
hand-edited file cannot become a runnable command.
CONSOLE & PACKAGING
- REPL with a / command palette, @ tool mentions, tag filters, history and
completion; background tmux panes; settings and first-run scaffolding.
- src-layout package with catalog and pipelines as package data, console entry
point, Docker image, signed releases with SBOM and build provenance.
- Health docs (SECURITY, CONTRIBUTING, CHANGELOG, CODE_OF_CONDUCT), a CI gate
(ruff + pytest + catalog/taxonomy conformance) and a pre-push hook.
- README rewritten with a section index, the tool catalog split into
docs/TOOLS.md and a step-by-step docs/HOW-TO-USE.md.
278 tests passing; scripts/check.sh green.
217 lines
8.2 KiB
Python
217 lines
8.2 KiB
Python
import pytest
|
|
|
|
from hackingtool import config
|
|
|
|
|
|
@pytest.fixture
|
|
def tmp_cfg(tmp_path, monkeypatch):
|
|
f = tmp_path / "config.json"
|
|
monkeypatch.setattr(config, "USER_CONFIG_FILE", f)
|
|
return f
|
|
|
|
|
|
def test_set_value_roundtrip(tmp_cfg):
|
|
ok, msg = config.set_value("background_runner", "off")
|
|
assert ok
|
|
assert config.load()["background_runner"] == "off"
|
|
|
|
|
|
def test_set_value_bool_coerce(tmp_cfg):
|
|
ok, _ = config.set_value("show_archived", "on")
|
|
assert ok and config.load()["show_archived"] is True
|
|
config.set_value("show_archived", "false")
|
|
assert config.load()["show_archived"] is False
|
|
|
|
|
|
def test_set_value_rejects_unknown(tmp_cfg):
|
|
ok, msg = config.set_value("bogus", "x")
|
|
assert not ok and "Unknown" in msg
|
|
assert not tmp_cfg.exists()
|
|
|
|
|
|
def test_set_value_rejects_readonly(tmp_cfg):
|
|
ok, msg = config.set_value("version", "9.9")
|
|
assert not ok and "read-only" in msg
|
|
assert not tmp_cfg.exists()
|
|
|
|
|
|
def test_set_value_rejects_bad_enum(tmp_cfg):
|
|
ok, msg = config.set_value("background_runner", "maybe")
|
|
assert not ok and "must be one of" in msg
|
|
assert not tmp_cfg.exists()
|
|
|
|
|
|
def test_set_value_unique_prefix(tmp_cfg):
|
|
ok, _ = config.set_value("background", "off")
|
|
assert ok and config.load()["background_runner"] == "off"
|
|
|
|
|
|
def test_describe_marks_version_readonly():
|
|
editable = {k: e for k, _, e in config.describe()}
|
|
assert editable["version"] is False
|
|
assert editable["background_runner"] is True
|
|
|
|
|
|
def test_ensure_user_files_scaffolds(tmp_cfg):
|
|
config.ensure_user_files()
|
|
assert tmp_cfg.exists() # config.json from defaults
|
|
env = tmp_cfg.parent / ".env"
|
|
assert env.exists()
|
|
body = env.read_text()
|
|
# Security: the template must NEVER ship an active secret — every AI-key
|
|
# line stays commented out.
|
|
for line in body.splitlines():
|
|
if "HACKINGTOOL_AI_KEY" in line:
|
|
assert line.lstrip().startswith("#")
|
|
|
|
|
|
def test_set_ai_key_writes_env_not_config(tmp_cfg, monkeypatch):
|
|
import stat
|
|
monkeypatch.delenv("HACKINGTOOL_AI_KEY", raising=False) # ensure clean + auto-restore
|
|
env = tmp_cfg.parent / ".env"
|
|
env.write_text("# hackingtool\nHACKINGTOOL_AI_MODEL=claude-x\n"
|
|
"# HACKINGTOOL_AI_KEY=sk-ant-your-key-here\n")
|
|
|
|
ok, _ = config.set_ai_key("sk-ant-real-123")
|
|
assert ok
|
|
body = env.read_text()
|
|
assert "HACKINGTOOL_AI_KEY=sk-ant-real-123" in body # written, uncommented
|
|
assert "HACKINGTOOL_AI_MODEL=claude-x" in body # other lines preserved
|
|
assert stat.S_IMODE(env.stat().st_mode) == 0o600 # owner-only
|
|
assert config.ai_key() == "sk-ant-real-123" # live in-process, no restart
|
|
assert not tmp_cfg.exists() or "sk-ant-real-123" not in tmp_cfg.read_text() # never in config.json
|
|
|
|
ok, _ = config.set_ai_key("") # clearing re-hides + unsets
|
|
assert ok and config.ai_key() == ""
|
|
assert "sk-ant-real-123" not in env.read_text()
|
|
|
|
|
|
def test_ensure_user_files_never_overwrites(tmp_cfg):
|
|
tmp_cfg.write_text('{"theme": "cyan"}') # pre-existing, hand-edited
|
|
env = tmp_cfg.parent / ".env"
|
|
env.write_text("# HACKINGTOOL_AI_KEY=sk-real-key\n")
|
|
config.ensure_user_files()
|
|
assert '"cyan"' in tmp_cfg.read_text() # config untouched
|
|
assert env.read_text() == "# HACKINGTOOL_AI_KEY=sk-real-key\n"
|
|
|
|
|
|
def test_allowed_values():
|
|
assert set(config.allowed_values("background_runner").split(", ")) == {"auto", "off"}
|
|
assert config.allowed_values("show_archived") == "true, false"
|
|
assert config.allowed_values("tools_dir") is None
|
|
|
|
|
|
def test_config_command_no_arg_lists(monkeypatch, capsys):
|
|
import hackingtool.cli as cli
|
|
cli.config_command("")
|
|
out = capsys.readouterr().out
|
|
assert "background_runner" in out and "version" in out
|
|
|
|
|
|
def test_config_command_sets(monkeypatch):
|
|
import hackingtool.cli as cli
|
|
from hackingtool import config
|
|
seen = {}
|
|
monkeypatch.setattr(config, "set_value",
|
|
lambda k, v: seen.setdefault("call", (k, v)) or (True, "ok"))
|
|
cli.config_command("background_runner off")
|
|
assert seen["call"] == ("background_runner", "off")
|
|
|
|
|
|
def test_config_command_show_single(monkeypatch, capsys):
|
|
import hackingtool.cli as cli
|
|
cli.config_command("background_runner")
|
|
out = capsys.readouterr().out
|
|
assert "background_runner" in out and "auto" in out
|
|
|
|
|
|
def test_config_command_show_unique_prefix(monkeypatch, capsys):
|
|
import hackingtool.cli as cli
|
|
cli.config_command("background")
|
|
out = capsys.readouterr().out
|
|
assert "background_runner" in out
|
|
|
|
|
|
# ── config_ui (modal editor) ───────────────────────────────────────────────────
|
|
def test_config_ui_rows(tmp_cfg):
|
|
from hackingtool import config_ui
|
|
rows = config_ui._rows()
|
|
by_key = {r["key"]: r for r in rows}
|
|
assert by_key["ai_provider"]["kind"] == "choice"
|
|
assert set(by_key["ai_provider"]["choices"]) == {"auto", "ollama", "openai-compat"}
|
|
assert by_key["theme"]["kind"] == "choice" # theme is now arrow-selectable
|
|
assert "magenta" in by_key["theme"]["choices"]
|
|
assert by_key["version"]["kind"] == "readonly"
|
|
assert by_key["ai_key"]["kind"] == "secret" # masked-editable → written to .env
|
|
assert rows[-1]["key"] == "ai_key"
|
|
|
|
|
|
def test_config_ui_cycle():
|
|
from hackingtool import config_ui
|
|
c = ["auto", "ollama", "openai-compat"]
|
|
assert config_ui._cycle_choice("auto", c) == "ollama" # → forward
|
|
assert config_ui._cycle_choice("openai-compat", c) == "auto" # → wraps
|
|
assert config_ui._cycle_choice("auto", c, -1) == "openai-compat" # ← wraps back
|
|
assert config_ui._cycle_choice("ollama", c, -1) == "auto" # ← backward
|
|
assert config_ui._cycle_choice("bogus", c) == "auto" # unknown → first
|
|
|
|
|
|
def test_config_command_no_arg_opens_modal_on_tty(monkeypatch):
|
|
import hackingtool.cli as cli
|
|
from hackingtool import prompt, config_ui
|
|
opened = {}
|
|
monkeypatch.setattr(prompt, "_use_pt", lambda: True)
|
|
monkeypatch.setattr(config_ui, "open_editor", lambda: opened.setdefault("hit", True))
|
|
cli.config_command("")
|
|
assert opened.get("hit") is True
|
|
|
|
|
|
# ── /config github (discover token check) ──────────────────────────────────────
|
|
def test_check_token_reports_missing(monkeypatch):
|
|
from hackingtool import discover
|
|
for name in ("HACKINGTOOL_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"):
|
|
monkeypatch.delenv(name, raising=False)
|
|
ok, detail = discover.check_token()
|
|
assert ok is False
|
|
assert "no token" in detail.lower()
|
|
|
|
|
|
def test_check_token_reports_limit(monkeypatch):
|
|
from hackingtool import discover
|
|
monkeypatch.setenv("HACKINGTOOL_GITHUB_TOKEN", "ghp_x")
|
|
monkeypatch.setattr(discover, "_fetch",
|
|
lambda url: {"resources": {"search": {"limit": 30}}})
|
|
ok, detail = discover.check_token()
|
|
assert ok is True and "30" in detail
|
|
|
|
|
|
def test_token_steps_never_leak_a_real_token():
|
|
from hackingtool.discover import GITHUB_TOKEN_STEPS
|
|
assert "ghp_" not in GITHUB_TOKEN_STEPS.replace("ghp_your-token-here", "")
|
|
assert "no permissions" in GITHUB_TOKEN_STEPS.lower()
|
|
|
|
|
|
def test_check_token_never_leaks_token_on_rejection(monkeypatch):
|
|
"""Token hygiene: a failed check_token() must not echo the token value
|
|
anywhere in its detail string, even indirectly via an exception message."""
|
|
from hackingtool import discover
|
|
secret = "ghp_supersecrettoken12345"
|
|
monkeypatch.setenv("HACKINGTOOL_GITHUB_TOKEN", secret)
|
|
|
|
def _boom(url):
|
|
raise ValueError(f"bad request to {url}")
|
|
monkeypatch.setattr(discover, "_fetch", _boom)
|
|
ok, detail = discover.check_token()
|
|
assert ok is False
|
|
assert secret not in detail
|
|
|
|
|
|
def test_config_command_github_no_token(monkeypatch, capsys):
|
|
import hackingtool.cli as cli
|
|
from hackingtool import discover
|
|
monkeypatch.setattr(discover, "check_token", lambda: (False, "no token configured"))
|
|
cli.config_command("github")
|
|
out = capsys.readouterr().out
|
|
assert "GitHub" in out
|
|
assert "no permissions" in out.lower()
|