huntkit 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- huntkit/__init__.py +4 -0
- huntkit/__main__.py +8 -0
- huntkit/ai/__init__.py +42 -0
- huntkit/ai/advisor.py +133 -0
- huntkit/ai/providers.py +200 -0
- huntkit/app/__init__.py +13 -0
- huntkit/app/config_cmd.py +65 -0
- huntkit/app/context.py +50 -0
- huntkit/app/main.py +1166 -0
- huntkit/app/plugins_cmd.py +79 -0
- huntkit/app/workspace_cmd.py +62 -0
- huntkit/core/__init__.py +6 -0
- huntkit/core/cache.py +67 -0
- huntkit/core/config.py +272 -0
- huntkit/core/logger.py +66 -0
- huntkit/core/pacing.py +71 -0
- huntkit/core/runner.py +117 -0
- huntkit/core/state.py +154 -0
- huntkit/core/workspace.py +145 -0
- huntkit/enrich/__init__.py +39 -0
- huntkit/enrich/asn.py +139 -0
- huntkit/enrich/engine.py +167 -0
- huntkit/enrich/favicon.py +91 -0
- huntkit/enrich/mmh3.py +69 -0
- huntkit/fingerprint/__init__.py +26 -0
- huntkit/fingerprint/engine.py +83 -0
- huntkit/fingerprint/rules.py +170 -0
- huntkit/history/__init__.py +38 -0
- huntkit/history/diff.py +95 -0
- huntkit/history/store.py +133 -0
- huntkit/intel/__init__.py +48 -0
- huntkit/intel/engine.py +233 -0
- huntkit/intel/signals.py +826 -0
- huntkit/knowledge/__init__.py +27 -0
- huntkit/knowledge/playbooks.py +452 -0
- huntkit/monitor/__init__.py +24 -0
- huntkit/monitor/runner.py +111 -0
- huntkit/notify/__init__.py +16 -0
- huntkit/notify/webhook.py +73 -0
- huntkit/pipeline.py +302 -0
- huntkit/plugins/__init__.py +36 -0
- huntkit/plugins/amass.py +25 -0
- huntkit/plugins/arjun.py +18 -0
- huntkit/plugins/assetfinder.py +18 -0
- huntkit/plugins/base.py +220 -0
- huntkit/plugins/chaos.py +20 -0
- huntkit/plugins/dalfox.py +24 -0
- huntkit/plugins/dnsx.py +19 -0
- huntkit/plugins/ffuf.py +34 -0
- huntkit/plugins/findomain.py +18 -0
- huntkit/plugins/gau.py +18 -0
- huntkit/plugins/hakrawler.py +19 -0
- huntkit/plugins/httpx.py +30 -0
- huntkit/plugins/katana.py +23 -0
- huntkit/plugins/naabu.py +22 -0
- huntkit/plugins/nmap.py +39 -0
- huntkit/plugins/nuclei.py +27 -0
- huntkit/plugins/registry.py +117 -0
- huntkit/plugins/subfinder.py +21 -0
- huntkit/plugins/waybackurls.py +29 -0
- huntkit/plugins/whatweb.py +19 -0
- huntkit/py.typed +0 -0
- huntkit/report/__init__.py +103 -0
- huntkit/report/builder.py +145 -0
- huntkit/report/render.py +323 -0
- huntkit/scanner/__init__.py +28 -0
- huntkit/scanner/runner.py +239 -0
- huntkit/secrets/__init__.py +34 -0
- huntkit/secrets/patterns.py +172 -0
- huntkit/secrets/scanner.py +133 -0
- huntkit/shots/__init__.py +29 -0
- huntkit/shots/runner.py +186 -0
- huntkit/tui/__init__.py +13 -0
- huntkit/tui/dashboard.py +160 -0
- huntkit/utils/__init__.py +6 -0
- huntkit/utils/filesystem.py +109 -0
- huntkit/utils/process.py +104 -0
- huntkit/utils/severity.py +49 -0
- huntkit/utils/terminal.py +88 -0
- huntkit/utils/validators.py +99 -0
- huntkit-0.3.0.dist-info/METADATA +340 -0
- huntkit-0.3.0.dist-info/RECORD +86 -0
- huntkit-0.3.0.dist-info/WHEEL +5 -0
- huntkit-0.3.0.dist-info/entry_points.txt +2 -0
- huntkit-0.3.0.dist-info/licenses/LICENSE +21 -0
- huntkit-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""`huntkit plugins` — browse the tool plugins HuntKit can drive."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from ..plugins import Category
|
|
10
|
+
from ..utils import terminal as term
|
|
11
|
+
|
|
12
|
+
plugins_app = typer.Typer(no_args_is_help=True, help="Browse and inspect tool plugins.")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _status(available: bool) -> str:
|
|
16
|
+
return "[ok]installed[/ok]" if available else "[muted]missing[/muted]"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@plugins_app.command("list")
|
|
20
|
+
def plugins_list(
|
|
21
|
+
ctx: typer.Context,
|
|
22
|
+
category: Optional[str] = typer.Option(
|
|
23
|
+
None, "--category", "-c", help="Filter by category (discovery/resolve/ports/urls/scan)."
|
|
24
|
+
),
|
|
25
|
+
available_only: bool = typer.Option(
|
|
26
|
+
False, "--available", "-a", help="Show only installed tools."
|
|
27
|
+
),
|
|
28
|
+
) -> None:
|
|
29
|
+
"""List plugins, their capability chain, and install status."""
|
|
30
|
+
app_ctx = ctx.obj
|
|
31
|
+
registry, runner = app_ctx.registry, app_ctx.runner
|
|
32
|
+
|
|
33
|
+
plugins = registry.all()
|
|
34
|
+
if category:
|
|
35
|
+
try:
|
|
36
|
+
plugins = registry.by_category(Category(category))
|
|
37
|
+
except ValueError:
|
|
38
|
+
term.error(f"unknown category: {category}")
|
|
39
|
+
raise typer.Exit(2) from None
|
|
40
|
+
if available_only:
|
|
41
|
+
plugins = [p for p in plugins if p.is_available(runner)]
|
|
42
|
+
|
|
43
|
+
table = term.make_table("Plugins", ["tool", "category", "consumes", "produces", "status"])
|
|
44
|
+
for p in plugins:
|
|
45
|
+
table.add_row(
|
|
46
|
+
p.name,
|
|
47
|
+
p.category.value,
|
|
48
|
+
p.consumes.value if p.consumes else "-",
|
|
49
|
+
p.produces.value,
|
|
50
|
+
_status(p.is_available(runner)),
|
|
51
|
+
)
|
|
52
|
+
term.console.print(table)
|
|
53
|
+
installed = len(registry.available(runner))
|
|
54
|
+
term.info(f"{installed}/{len(registry)} tools installed.")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@plugins_app.command("show")
|
|
58
|
+
def plugins_show(ctx: typer.Context, name: str = typer.Argument(..., help="Plugin name.")) -> None:
|
|
59
|
+
"""Show one plugin's metadata and how to install it."""
|
|
60
|
+
app_ctx = ctx.obj
|
|
61
|
+
plugin = app_ctx.registry.get(name)
|
|
62
|
+
if plugin is None:
|
|
63
|
+
term.error(f"no such plugin: {name}")
|
|
64
|
+
raise typer.Exit(2)
|
|
65
|
+
available = plugin.is_available(app_ctx.runner)
|
|
66
|
+
rows = [
|
|
67
|
+
("name", plugin.name),
|
|
68
|
+
("category", plugin.category.value),
|
|
69
|
+
("description", plugin.description or "-"),
|
|
70
|
+
("consumes", plugin.consumes.value if plugin.consumes else "-"),
|
|
71
|
+
("produces", plugin.produces.value),
|
|
72
|
+
("input mode", plugin.input_mode.value),
|
|
73
|
+
("status", "installed" if available else "missing"),
|
|
74
|
+
]
|
|
75
|
+
if plugin.needs_api_key:
|
|
76
|
+
rows.append(("api key", plugin.needs_api_key))
|
|
77
|
+
if not available:
|
|
78
|
+
rows.append(("install", plugin.install))
|
|
79
|
+
term.print_table(f"plugin: {plugin.name}", ["field", "value"], rows)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""`huntkit workspace` — list and inspect engagement workspaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from ..core.workspace import Workspace, default_base, list_workspaces
|
|
8
|
+
from ..utils import terminal as term
|
|
9
|
+
|
|
10
|
+
workspace_app = typer.Typer(no_args_is_help=True, help="List and inspect workspaces.")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@workspace_app.command("list")
|
|
14
|
+
def workspace_list(ctx: typer.Context) -> None:
|
|
15
|
+
"""List every workspace under the workspace root."""
|
|
16
|
+
app_ctx = ctx.obj
|
|
17
|
+
base = app_ctx.base or default_base(app_ctx.config)
|
|
18
|
+
names = list_workspaces(base=base)
|
|
19
|
+
if not names:
|
|
20
|
+
term.warn(f"no workspaces yet under {base}")
|
|
21
|
+
term.info("create one with: huntkit init <program> -s <domain>")
|
|
22
|
+
return
|
|
23
|
+
rows = []
|
|
24
|
+
for name in names:
|
|
25
|
+
ws = Workspace.open(name, config=app_ctx.config, base=app_ctx.base)
|
|
26
|
+
subs = ws.count("recon/subdomains.txt")
|
|
27
|
+
live = ws.count("recon/live.txt")
|
|
28
|
+
rows.append((name, str(subs), str(live), str(len(ws.scope_in))))
|
|
29
|
+
term.print_table(f"Workspaces ({base})", ["program", "subs", "live", "scope"], rows)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@workspace_app.command("show")
|
|
33
|
+
def workspace_show(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
program: str = typer.Argument(..., help="Workspace / program name."),
|
|
36
|
+
) -> None:
|
|
37
|
+
"""Show scope, counts, and stage status for one workspace."""
|
|
38
|
+
app_ctx = ctx.obj
|
|
39
|
+
ws = Workspace.open(program, config=app_ctx.config, base=app_ctx.base)
|
|
40
|
+
if not ws.has_scope() and not ws.state.counts:
|
|
41
|
+
term.warn(f"workspace '{program}' looks empty ({ws.root})")
|
|
42
|
+
|
|
43
|
+
term.banner(f"workspace: {program}")
|
|
44
|
+
term.info(f"path: {ws.root}")
|
|
45
|
+
|
|
46
|
+
scope_in = ws.scope_in or ["(none — everything in scope)"]
|
|
47
|
+
term.step("Scope")
|
|
48
|
+
for entry in scope_in:
|
|
49
|
+
term.bullet(entry, style="ok")
|
|
50
|
+
for entry in ws.scope_out:
|
|
51
|
+
term.bullet(f"{entry} (excluded)", style="warn")
|
|
52
|
+
|
|
53
|
+
if ws.state.counts:
|
|
54
|
+
term.print_table(
|
|
55
|
+
"Assets", ["metric", "count"], [(k, str(v)) for k, v in ws.state.counts.items()]
|
|
56
|
+
)
|
|
57
|
+
if ws.state.stages:
|
|
58
|
+
rows = []
|
|
59
|
+
for name, rec in ws.state.stages.items():
|
|
60
|
+
dur = f"{rec.duration:.1f}s" if rec.duration else "-"
|
|
61
|
+
rows.append((name, rec.status.value, dur))
|
|
62
|
+
term.print_table("Stages", ["stage", "status", "time"], rows)
|
huntkit/core/__init__.py
ADDED
huntkit/core/cache.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Content-addressed result cache.
|
|
2
|
+
|
|
3
|
+
Skips re-running an expensive tool when the same command + input has already
|
|
4
|
+
produced output within a TTL. Keys are hashes so the same passive enumeration
|
|
5
|
+
isn't repeated across runs of a workspace. Entirely local; lives in the
|
|
6
|
+
workspace `cache/` directory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
|
|
17
|
+
from ..utils import filesystem as fs
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def make_key(*parts: str) -> str:
|
|
21
|
+
"""Stable cache key from arbitrary string parts (tool, args, stdin)."""
|
|
22
|
+
digest = hashlib.sha256("\x00".join(parts).encode("utf-8")).hexdigest()
|
|
23
|
+
return digest[:32]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Cache:
|
|
27
|
+
def __init__(self, root: Path, ttl_seconds: int = 86_400, enabled: bool = True) -> None:
|
|
28
|
+
self.root = root
|
|
29
|
+
self.ttl = ttl_seconds
|
|
30
|
+
self.enabled = enabled
|
|
31
|
+
|
|
32
|
+
def _path(self, key: str) -> Path:
|
|
33
|
+
return self.root / f"{key}.json"
|
|
34
|
+
|
|
35
|
+
def get(self, key: str) -> Optional[str]:
|
|
36
|
+
if not self.enabled:
|
|
37
|
+
return None
|
|
38
|
+
path = self._path(key)
|
|
39
|
+
if not path.exists():
|
|
40
|
+
return None
|
|
41
|
+
try:
|
|
42
|
+
record = json.loads(fs.read_text(path))
|
|
43
|
+
except (json.JSONDecodeError, OSError):
|
|
44
|
+
return None
|
|
45
|
+
if self.ttl and time.time() - record.get("ts", 0) > self.ttl:
|
|
46
|
+
return None
|
|
47
|
+
return record.get("value")
|
|
48
|
+
|
|
49
|
+
def set(self, key: str, value: str) -> None:
|
|
50
|
+
if not self.enabled:
|
|
51
|
+
return
|
|
52
|
+
fs.ensure_dir(self.root)
|
|
53
|
+
fs.write_text(self._path(key), json.dumps({"ts": time.time(), "value": value}))
|
|
54
|
+
|
|
55
|
+
def invalidate(self, key: str) -> None:
|
|
56
|
+
path = self._path(key)
|
|
57
|
+
if path.exists():
|
|
58
|
+
path.unlink()
|
|
59
|
+
|
|
60
|
+
def clear(self) -> int:
|
|
61
|
+
if not self.root.exists():
|
|
62
|
+
return 0
|
|
63
|
+
removed = 0
|
|
64
|
+
for f in self.root.glob("*.json"):
|
|
65
|
+
f.unlink()
|
|
66
|
+
removed += 1
|
|
67
|
+
return removed
|
huntkit/core/config.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""Layered YAML configuration.
|
|
2
|
+
|
|
3
|
+
Resolution order (later wins):
|
|
4
|
+
1. built-in defaults (this module)
|
|
5
|
+
2. user config ~/.config/huntkit/huntkit.yaml (or $HUNTKIT_CONFIG_HOME)
|
|
6
|
+
3. project config ./huntkit.yaml (cwd, walked upward)
|
|
7
|
+
4. explicit --config path
|
|
8
|
+
5. environment HUNTKIT_<SECTION>_<KEY>
|
|
9
|
+
|
|
10
|
+
Everything is typed via dataclasses so the rest of the codebase gets
|
|
11
|
+
autocomplete and mypy checking instead of dictionary spelunking.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import asdict, dataclass, field, fields, is_dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Optional
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
|
|
23
|
+
CONFIG_FILENAME = "huntkit.yaml"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# --------------------------------------------------------------------------
|
|
27
|
+
# Typed sections
|
|
28
|
+
# --------------------------------------------------------------------------
|
|
29
|
+
@dataclass
|
|
30
|
+
class GeneralConfig:
|
|
31
|
+
workspace_path: str = "~/.huntkit"
|
|
32
|
+
threads: int = 20
|
|
33
|
+
rate: int = 150 # requests/sec hint passed to rate-aware tools
|
|
34
|
+
timeout: int = 300 # per-command seconds
|
|
35
|
+
retry: int = 1 # retries on transient failure
|
|
36
|
+
default_report_format: str = "markdown"
|
|
37
|
+
color: bool = True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class HttpConfig:
|
|
42
|
+
proxy: Optional[str] = None # e.g. http://127.0.0.1:8080 (Burp)
|
|
43
|
+
user_agent: str = "HuntKit/0.2 (+https://github.com/Raresney/HuntKit)"
|
|
44
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
45
|
+
rate_limit: int = 150 # HTTP requests/sec hint passed to rate-aware tools
|
|
46
|
+
delay: float = 0.0 # seconds between requests for tools that support it
|
|
47
|
+
follow_redirects: bool = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class ToolConfig:
|
|
52
|
+
"""Per-tool overrides. `path` pins a binary; `extra_args` is appended."""
|
|
53
|
+
|
|
54
|
+
enabled: bool = True
|
|
55
|
+
path: Optional[str] = None
|
|
56
|
+
extra_args: list[str] = field(default_factory=list)
|
|
57
|
+
timeout: Optional[int] = None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class WordlistConfig:
|
|
62
|
+
dir_fuzz: Optional[str] = None
|
|
63
|
+
dns: Optional[str] = None
|
|
64
|
+
params: Optional[str] = None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class NucleiConfig:
|
|
69
|
+
templates: Optional[str] = None # custom template dir
|
|
70
|
+
severity: str = "low,medium,high,critical"
|
|
71
|
+
extra_args: list[str] = field(default_factory=list)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass
|
|
75
|
+
class AIConfig:
|
|
76
|
+
provider: str = "ollama" # ollama|openai|claude|gemini
|
|
77
|
+
model: str = "llama3.2"
|
|
78
|
+
endpoint: str = "http://127.0.0.1:11434"
|
|
79
|
+
require_approval: bool = True # never send engagement data silently
|
|
80
|
+
max_context_items: int = 40
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Config:
|
|
85
|
+
general: GeneralConfig = field(default_factory=GeneralConfig)
|
|
86
|
+
http: HttpConfig = field(default_factory=HttpConfig)
|
|
87
|
+
wordlists: WordlistConfig = field(default_factory=WordlistConfig)
|
|
88
|
+
nuclei: NucleiConfig = field(default_factory=NucleiConfig)
|
|
89
|
+
ai: AIConfig = field(default_factory=AIConfig)
|
|
90
|
+
tools: dict[str, ToolConfig] = field(default_factory=dict)
|
|
91
|
+
api_keys: dict[str, str] = field(default_factory=dict)
|
|
92
|
+
|
|
93
|
+
# provenance (not serialised into user config)
|
|
94
|
+
_sources: list[str] = field(default_factory=list, repr=False)
|
|
95
|
+
|
|
96
|
+
# ---- construction ----------------------------------------------------
|
|
97
|
+
@classmethod
|
|
98
|
+
def load(cls, explicit_path: Optional[Path] = None) -> Config:
|
|
99
|
+
cfg = cls()
|
|
100
|
+
for path in _candidate_paths(explicit_path):
|
|
101
|
+
data = _read_yaml(path)
|
|
102
|
+
if data:
|
|
103
|
+
cfg._merge(data)
|
|
104
|
+
cfg._sources.append(str(path))
|
|
105
|
+
cfg._apply_env()
|
|
106
|
+
return cfg
|
|
107
|
+
|
|
108
|
+
@classmethod
|
|
109
|
+
def from_dict(cls, data: dict[str, Any]) -> Config:
|
|
110
|
+
cfg = cls()
|
|
111
|
+
cfg._merge(data or {})
|
|
112
|
+
return cfg
|
|
113
|
+
|
|
114
|
+
# ---- access ----------------------------------------------------------
|
|
115
|
+
def tool(self, name: str) -> ToolConfig:
|
|
116
|
+
"""Return the (possibly default) config for a named tool."""
|
|
117
|
+
return self.tools.get(name, ToolConfig())
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def workspace_root(self) -> Path:
|
|
121
|
+
return Path(self.general.workspace_path).expanduser()
|
|
122
|
+
|
|
123
|
+
def to_dict(self) -> dict[str, Any]:
|
|
124
|
+
data = _dataclass_to_dict(self)
|
|
125
|
+
data.pop("_sources", None)
|
|
126
|
+
return data
|
|
127
|
+
|
|
128
|
+
# ---- internal merge --------------------------------------------------
|
|
129
|
+
def _merge(self, data: dict[str, Any]) -> None:
|
|
130
|
+
for key in ("general", "http", "wordlists", "nuclei", "ai"):
|
|
131
|
+
if key in data and isinstance(data[key], dict):
|
|
132
|
+
_merge_into(getattr(self, key), data[key])
|
|
133
|
+
if "tools" in data and isinstance(data["tools"], dict):
|
|
134
|
+
for name, tdata in data["tools"].items():
|
|
135
|
+
base = self.tools.get(name, ToolConfig())
|
|
136
|
+
_merge_into(base, tdata or {})
|
|
137
|
+
self.tools[name] = base
|
|
138
|
+
if "api_keys" in data and isinstance(data["api_keys"], dict):
|
|
139
|
+
self.api_keys.update({k: str(v) for k, v in data["api_keys"].items()})
|
|
140
|
+
|
|
141
|
+
def _apply_env(self) -> None:
|
|
142
|
+
"""Override scalars from HUNTKIT_<SECTION>_<KEY> environment vars."""
|
|
143
|
+
for section_name in ("general", "http", "ai", "nuclei"):
|
|
144
|
+
section = getattr(self, section_name)
|
|
145
|
+
for f in fields(section):
|
|
146
|
+
env_key = f"HUNTKIT_{section_name.upper()}_{f.name.upper()}"
|
|
147
|
+
if env_key in os.environ:
|
|
148
|
+
setattr(section, f.name, _coerce(os.environ[env_key], f.type))
|
|
149
|
+
# API keys: HUNTKIT_APIKEY_<NAME>
|
|
150
|
+
for env_key, val in os.environ.items():
|
|
151
|
+
if env_key.startswith("HUNTKIT_APIKEY_"):
|
|
152
|
+
self.api_keys[env_key[len("HUNTKIT_APIKEY_") :].lower()] = val
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# --------------------------------------------------------------------------
|
|
156
|
+
# Helpers
|
|
157
|
+
# --------------------------------------------------------------------------
|
|
158
|
+
def _candidate_paths(explicit: Optional[Path]) -> list[Path]:
|
|
159
|
+
paths: list[Path] = []
|
|
160
|
+
# user-level
|
|
161
|
+
home = os.environ.get("HUNTKIT_CONFIG_HOME")
|
|
162
|
+
user_dir = Path(home).expanduser() if home else Path.home() / ".config" / "huntkit"
|
|
163
|
+
paths.append(user_dir / CONFIG_FILENAME)
|
|
164
|
+
# project-level: nearest huntkit.yaml walking up from cwd
|
|
165
|
+
project = _find_upwards(Path.cwd(), CONFIG_FILENAME)
|
|
166
|
+
if project:
|
|
167
|
+
paths.append(project)
|
|
168
|
+
if explicit:
|
|
169
|
+
paths.append(Path(explicit).expanduser())
|
|
170
|
+
return paths
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _find_upwards(start: Path, filename: str) -> Optional[Path]:
|
|
174
|
+
for parent in [start, *start.parents]:
|
|
175
|
+
candidate = parent / filename
|
|
176
|
+
if candidate.is_file():
|
|
177
|
+
return candidate
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _read_yaml(path: Path) -> Optional[dict[str, Any]]:
|
|
182
|
+
try:
|
|
183
|
+
if not path.is_file():
|
|
184
|
+
return None
|
|
185
|
+
loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
186
|
+
return loaded if isinstance(loaded, dict) else None
|
|
187
|
+
except (OSError, yaml.YAMLError):
|
|
188
|
+
return None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _merge_into(obj: Any, data: dict[str, Any]) -> None:
|
|
192
|
+
"""Shallow-merge dict values onto a dataclass instance, by field name."""
|
|
193
|
+
valid = {f.name for f in fields(obj)}
|
|
194
|
+
for key, value in data.items():
|
|
195
|
+
if key in valid and value is not None:
|
|
196
|
+
setattr(obj, key, value)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _coerce(raw: str, target_type: Any) -> Any:
|
|
200
|
+
type_str = str(target_type)
|
|
201
|
+
if "int" in type_str:
|
|
202
|
+
try:
|
|
203
|
+
return int(raw)
|
|
204
|
+
except ValueError:
|
|
205
|
+
return raw
|
|
206
|
+
if "bool" in type_str:
|
|
207
|
+
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
|
208
|
+
return raw
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _dataclass_to_dict(obj: Any) -> Any:
|
|
212
|
+
if is_dataclass(obj) and not isinstance(obj, type):
|
|
213
|
+
return {k: _dataclass_to_dict(v) for k, v in asdict(obj).items()}
|
|
214
|
+
return obj
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def sample_yaml() -> str:
|
|
218
|
+
"""Render a documented default config for `huntkit config init`."""
|
|
219
|
+
return _SAMPLE_YAML
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
_SAMPLE_YAML = """\
|
|
223
|
+
# HuntKit configuration — https://github.com/Raresney/HuntKit
|
|
224
|
+
# Place at ~/.config/huntkit/huntkit.yaml (global) or ./huntkit.yaml (project).
|
|
225
|
+
# Every value below is a default; delete what you don't need to override.
|
|
226
|
+
|
|
227
|
+
general:
|
|
228
|
+
workspace_path: ~/.huntkit
|
|
229
|
+
threads: 20
|
|
230
|
+
rate: 150
|
|
231
|
+
timeout: 300
|
|
232
|
+
retry: 1
|
|
233
|
+
default_report_format: markdown # markdown | html | json
|
|
234
|
+
color: true
|
|
235
|
+
|
|
236
|
+
http:
|
|
237
|
+
proxy: null # e.g. http://127.0.0.1:8080 for Burp
|
|
238
|
+
user_agent: "HuntKit/0.2 (+https://github.com/Raresney/HuntKit)"
|
|
239
|
+
headers: {} # e.g. { X-Bug-Bounty: my-handle }
|
|
240
|
+
rate_limit: 150
|
|
241
|
+
follow_redirects: true
|
|
242
|
+
|
|
243
|
+
wordlists:
|
|
244
|
+
dir_fuzz: null # e.g. /usr/share/seclists/.../common.txt
|
|
245
|
+
dns: null
|
|
246
|
+
params: null
|
|
247
|
+
|
|
248
|
+
nuclei:
|
|
249
|
+
templates: null # custom template directory
|
|
250
|
+
severity: low,medium,high,critical
|
|
251
|
+
extra_args: []
|
|
252
|
+
|
|
253
|
+
ai:
|
|
254
|
+
provider: ollama # ollama | openai | claude | gemini
|
|
255
|
+
model: llama3.2
|
|
256
|
+
endpoint: http://127.0.0.1:11434
|
|
257
|
+
require_approval: true # never send recon data without a prompt
|
|
258
|
+
max_context_items: 40
|
|
259
|
+
|
|
260
|
+
# Per-tool overrides. Pin a binary path, disable a tool, or append args.
|
|
261
|
+
tools:
|
|
262
|
+
amass:
|
|
263
|
+
enabled: true
|
|
264
|
+
# path: /opt/amass/amass
|
|
265
|
+
# extra_args: ["-active"]
|
|
266
|
+
|
|
267
|
+
# Secrets pulled by data-source tools. Prefer env vars: HUNTKIT_APIKEY_<NAME>.
|
|
268
|
+
api_keys: {}
|
|
269
|
+
# chaos: "..."
|
|
270
|
+
# github: "..."
|
|
271
|
+
# shodan: "..."
|
|
272
|
+
"""
|
huntkit/core/logger.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Structured logging built on Rich.
|
|
2
|
+
|
|
3
|
+
Console logging is human-friendly; an optional rotating file handler writes a
|
|
4
|
+
full engagement log into the workspace `logs/` directory. Feature modules
|
|
5
|
+
call `get_logger(__name__)` and never touch handler configuration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from rich.logging import RichHandler
|
|
15
|
+
|
|
16
|
+
from ..utils.terminal import console
|
|
17
|
+
|
|
18
|
+
_CONFIGURED = False
|
|
19
|
+
_ROOT = "huntkit"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def setup_logging(
|
|
23
|
+
level: str = "INFO",
|
|
24
|
+
logfile: Optional[Path] = None,
|
|
25
|
+
*,
|
|
26
|
+
verbose: bool = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Configure the `huntkit` logger tree once.
|
|
29
|
+
|
|
30
|
+
Safe to call repeatedly (e.g. after a workspace is opened to attach a
|
|
31
|
+
file handler) — handlers are reset rather than duplicated.
|
|
32
|
+
"""
|
|
33
|
+
global _CONFIGURED
|
|
34
|
+
logger = logging.getLogger(_ROOT)
|
|
35
|
+
logger.setLevel(logging.DEBUG if verbose else getattr(logging, level.upper(), logging.INFO))
|
|
36
|
+
logger.handlers.clear()
|
|
37
|
+
logger.propagate = False
|
|
38
|
+
|
|
39
|
+
rich_handler = RichHandler(
|
|
40
|
+
console=console,
|
|
41
|
+
show_time=verbose,
|
|
42
|
+
show_path=verbose,
|
|
43
|
+
rich_tracebacks=True,
|
|
44
|
+
markup=True,
|
|
45
|
+
)
|
|
46
|
+
rich_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
47
|
+
logger.addHandler(rich_handler)
|
|
48
|
+
|
|
49
|
+
if logfile is not None:
|
|
50
|
+
logfile.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
file_handler = logging.FileHandler(logfile, encoding="utf-8")
|
|
52
|
+
file_handler.setLevel(logging.DEBUG)
|
|
53
|
+
file_handler.setFormatter(
|
|
54
|
+
logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s")
|
|
55
|
+
)
|
|
56
|
+
logger.addHandler(file_handler)
|
|
57
|
+
|
|
58
|
+
_CONFIGURED = True
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_logger(name: str = _ROOT) -> logging.Logger:
|
|
62
|
+
if not _CONFIGURED:
|
|
63
|
+
setup_logging()
|
|
64
|
+
if name == _ROOT or name.startswith(_ROOT + "."):
|
|
65
|
+
return logging.getLogger(name)
|
|
66
|
+
return logging.getLogger(f"{_ROOT}.{name}")
|
huntkit/core/pacing.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Politeness — one policy that paces every rate-aware tool.
|
|
2
|
+
|
|
3
|
+
Real bug-bounty programs ban noisy scanners, and "authorised" does not mean
|
|
4
|
+
"hammer the target". HuntKit keeps a single rate / delay / concurrency policy
|
|
5
|
+
(from config) and maps it onto each tool's native flags, so dialing a program
|
|
6
|
+
down to a gentle crawl is one edit, not twelve scattered ones.
|
|
7
|
+
|
|
8
|
+
Pure and offline: it only turns the policy into argv fragments.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from .config import Config
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Pace:
|
|
22
|
+
"""The politeness policy, normalised from config.
|
|
23
|
+
|
|
24
|
+
``rate`` is HTTP requests/sec; ``port_rate`` is packets/sec for the port
|
|
25
|
+
scanner (a different budget); ``delay`` is seconds between requests for the
|
|
26
|
+
tools that support it; ``concurrency`` caps workers. A zero rate/delay
|
|
27
|
+
means "no limit" / "no wait".
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
rate: int
|
|
31
|
+
port_rate: int
|
|
32
|
+
delay: float
|
|
33
|
+
concurrency: int
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_config(cls, config: Config) -> Pace:
|
|
37
|
+
return cls(
|
|
38
|
+
rate=max(0, config.http.rate_limit),
|
|
39
|
+
port_rate=max(0, config.general.rate),
|
|
40
|
+
delay=max(0.0, config.http.delay),
|
|
41
|
+
concurrency=max(1, config.general.threads),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def duration(seconds: float) -> str:
|
|
46
|
+
"""Render seconds as a ProjectDiscovery duration string: '500ms', '2s'."""
|
|
47
|
+
if seconds < 1:
|
|
48
|
+
return f"{int(round(seconds * 1000))}ms"
|
|
49
|
+
whole = int(seconds)
|
|
50
|
+
return f"{whole}s" if seconds == whole else f"{seconds}s"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def rate_arg(flag: str, rate: int) -> list[str]:
|
|
54
|
+
"""``[flag, rate]`` when a positive limit is set, else nothing."""
|
|
55
|
+
return [flag, str(rate)] if rate > 0 else []
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def delay_arg(flag: str, seconds: float, *, unit: str = "pd") -> list[str]:
|
|
59
|
+
"""``[flag, value]`` for an inter-request delay, in the tool's own unit.
|
|
60
|
+
|
|
61
|
+
``unit`` picks the rendering: ``pd`` → a ProjectDiscovery duration string
|
|
62
|
+
('500ms'), ``sec`` → plain fractional seconds (ffuf ``-p``), ``ms`` → whole
|
|
63
|
+
milliseconds (dalfox ``--delay``). A non-positive delay emits nothing.
|
|
64
|
+
"""
|
|
65
|
+
if seconds <= 0:
|
|
66
|
+
return []
|
|
67
|
+
if unit == "sec":
|
|
68
|
+
return [flag, str(seconds)]
|
|
69
|
+
if unit == "ms":
|
|
70
|
+
return [flag, str(int(round(seconds * 1000)))]
|
|
71
|
+
return [flag, duration(seconds)]
|