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,73 @@
|
|
|
1
|
+
"""Chat-webhook delivery for monitor deltas (Slack / Discord incoming hooks)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
import urllib.parse
|
|
8
|
+
import urllib.request
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import TYPE_CHECKING, Optional
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from ..history import DiffReport
|
|
14
|
+
|
|
15
|
+
# POST a JSON payload to a URL; return True iff delivered. Injected in tests so
|
|
16
|
+
# nothing leaves the box.
|
|
17
|
+
PostFn = Callable[[str, dict], bool]
|
|
18
|
+
|
|
19
|
+
_TIMEOUT = 10
|
|
20
|
+
_ALLOWED_SCHEMES = ("http", "https")
|
|
21
|
+
_MAX_LISTED = 10 # cap how many new items we spell out before summarising
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _is_discord(url: str) -> bool:
|
|
25
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
26
|
+
return host.endswith("discord.com") or host.endswith("discordapp.com")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def summarise(report: DiffReport) -> str:
|
|
30
|
+
"""One compact, human-readable block describing the delta."""
|
|
31
|
+
lines = [f"HuntKit · {report.program} — changes since {report.base_id}"]
|
|
32
|
+
for cat in report.changed:
|
|
33
|
+
parts = []
|
|
34
|
+
if cat.added:
|
|
35
|
+
parts.append(f"+{len(cat.added)}")
|
|
36
|
+
if cat.removed:
|
|
37
|
+
parts.append(f"-{len(cat.removed)}")
|
|
38
|
+
lines.append(f" {cat.name}: {' '.join(parts)}")
|
|
39
|
+
|
|
40
|
+
new = report.new_findings
|
|
41
|
+
if new:
|
|
42
|
+
lines.append(f"NEW FINDINGS ({len(new)}):")
|
|
43
|
+
lines.extend(f" • {item}" for item in new[:_MAX_LISTED])
|
|
44
|
+
if len(new) > _MAX_LISTED:
|
|
45
|
+
lines.append(f" … and {len(new) - _MAX_LISTED} more")
|
|
46
|
+
return "\n".join(lines)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _payload(url: str, text: str) -> dict:
|
|
50
|
+
# Slack expects {"text": …}; Discord expects {"content": …}. Send the key
|
|
51
|
+
# the target renders, keyed off the host.
|
|
52
|
+
return {"content": text} if _is_discord(url) else {"text": text}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def default_post(url: str, payload: dict) -> bool:
|
|
56
|
+
"""POST ``payload`` as JSON. Scheme-guarded to http(s); never raises on a
|
|
57
|
+
network error — a down webhook shouldn't crash a sweep."""
|
|
58
|
+
scheme = urllib.parse.urlsplit(url).scheme.lower()
|
|
59
|
+
if scheme not in _ALLOWED_SCHEMES:
|
|
60
|
+
raise ValueError(f"refusing non-http(s) webhook URL (scheme: {scheme or 'none'})")
|
|
61
|
+
data = json.dumps(payload).encode("utf-8")
|
|
62
|
+
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
|
63
|
+
try:
|
|
64
|
+
with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
|
|
65
|
+
return 200 <= resp.status < 300
|
|
66
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def notify(url: str, report: DiffReport, *, post: Optional[PostFn] = None) -> bool:
|
|
71
|
+
"""Send the diff summary to the webhook. Returns True if delivered."""
|
|
72
|
+
sender = post or default_post
|
|
73
|
+
return sender(url, _payload(url, summarise(report)))
|
huntkit/pipeline.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
r"""Recon pipeline — turn a seed domain into deduped, in-scope assets.
|
|
2
|
+
|
|
3
|
+
Ties the plugin registry to a workspace and runs recon as a small dataflow:
|
|
4
|
+
|
|
5
|
+
subs -> resolve -> live -> urls
|
|
6
|
+
\-> ports
|
|
7
|
+
|
|
8
|
+
Stages run in dependency **waves**; every available plugin in a wave is
|
|
9
|
+
executed concurrently on a thread pool, and each stage's outputs are merged
|
|
10
|
+
once on the main thread — so file and state writes are never touched by more
|
|
11
|
+
than one thread. Completed stages are recorded, letting an interrupted run
|
|
12
|
+
**resume** where it left off, and identical tool invocations can be served
|
|
13
|
+
from a content-addressed **cache**.
|
|
14
|
+
|
|
15
|
+
The public surface (:class:`Pipeline`, :func:`run_recon`) stayed stable from
|
|
16
|
+
phase 3; phase 4 filled in the parallelism, resume, and caching behind it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from collections import defaultdict
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from typing import Optional
|
|
26
|
+
|
|
27
|
+
from .core.config import Config
|
|
28
|
+
from .core.runner import CommandRunner
|
|
29
|
+
from .core.workspace import Workspace
|
|
30
|
+
from .plugins import Capability, PluginContext, PluginResult, ToolPlugin, get_registry
|
|
31
|
+
from .plugins.registry import PluginRegistry
|
|
32
|
+
|
|
33
|
+
# Ordered recon stages and the plugins that feed each. Every *available*
|
|
34
|
+
# plugin in a stage runs and its output is merged, so more installed tools
|
|
35
|
+
# just means better coverage — no configuration required.
|
|
36
|
+
RECON_STAGES = ["subs", "resolve", "live", "ports", "urls"]
|
|
37
|
+
|
|
38
|
+
# Dependency waves: stages in the same wave run concurrently; a later wave
|
|
39
|
+
# starts only once the earlier ones finish (their output files are ready).
|
|
40
|
+
WAVES: list[list[str]] = [
|
|
41
|
+
["subs"],
|
|
42
|
+
["resolve"],
|
|
43
|
+
["live", "ports"],
|
|
44
|
+
["urls"],
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
STAGE_PLUGINS: dict[str, list[str]] = {
|
|
48
|
+
"subs": ["subfinder", "assetfinder", "amass", "findomain", "chaos"],
|
|
49
|
+
"resolve": ["dnsx"],
|
|
50
|
+
"live": ["httpx"],
|
|
51
|
+
"ports": ["naabu"],
|
|
52
|
+
"urls": ["gau", "waybackurls", "katana", "hakrawler"],
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
STAGE_FILES: dict[str, str] = {
|
|
56
|
+
"subs": "recon/subdomains.txt",
|
|
57
|
+
"resolve": "recon/resolved.txt",
|
|
58
|
+
"live": "recon/live.txt",
|
|
59
|
+
"ports": "recon/ports.txt",
|
|
60
|
+
"urls": "urls/urls.txt",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
STAGE_COUNTS: dict[str, str] = {
|
|
64
|
+
"subs": "subdomains",
|
|
65
|
+
"resolve": "resolved",
|
|
66
|
+
"live": "live",
|
|
67
|
+
"ports": "open_ports",
|
|
68
|
+
"urls": "urls",
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
# Plugins in the urls stage that walk from the seed domain rather than the
|
|
72
|
+
# live-host list.
|
|
73
|
+
_SEED_URL_PLUGINS = {"gau", "waybackurls"}
|
|
74
|
+
|
|
75
|
+
EventFn = Callable[[dict], None]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class StageOutcome:
|
|
80
|
+
stage: str
|
|
81
|
+
new: int = 0 # newly discovered items
|
|
82
|
+
total: int = 0 # total in the file afterwards
|
|
83
|
+
ran: list[str] = field(default_factory=list)
|
|
84
|
+
skipped: list[tuple[str, str]] = field(default_factory=list) # (plugin, reason)
|
|
85
|
+
resumed: bool = False # skipped because already done
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ReconSummary:
|
|
90
|
+
domain: str
|
|
91
|
+
stages: list[StageOutcome] = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def total_new(self) -> int:
|
|
95
|
+
return sum(s.new for s in self.stages)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class Pipeline:
|
|
99
|
+
def __init__(
|
|
100
|
+
self,
|
|
101
|
+
ws: Workspace,
|
|
102
|
+
config: Config,
|
|
103
|
+
*,
|
|
104
|
+
runner: Optional[CommandRunner] = None,
|
|
105
|
+
registry: Optional[PluginRegistry] = None,
|
|
106
|
+
on_event: Optional[EventFn] = None,
|
|
107
|
+
threads: Optional[int] = None,
|
|
108
|
+
use_cache: bool = False,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.ws = ws
|
|
111
|
+
self.config = config
|
|
112
|
+
self.runner = runner or CommandRunner(config)
|
|
113
|
+
# an empty registry is falsy (len 0), so test for None explicitly
|
|
114
|
+
self.registry = registry if registry is not None else get_registry()
|
|
115
|
+
self._emit: EventFn = on_event or (lambda _e: None)
|
|
116
|
+
self.threads = threads or config.general.threads
|
|
117
|
+
self.use_cache = use_cache
|
|
118
|
+
|
|
119
|
+
# ---- public API ------------------------------------------------------
|
|
120
|
+
def run(
|
|
121
|
+
self,
|
|
122
|
+
domain: str,
|
|
123
|
+
stages: Optional[list[str]] = None,
|
|
124
|
+
*,
|
|
125
|
+
resume: bool = True,
|
|
126
|
+
) -> ReconSummary:
|
|
127
|
+
wanted = set(stages or RECON_STAGES)
|
|
128
|
+
summary = ReconSummary(domain=domain)
|
|
129
|
+
# the seed itself is the first in-scope asset
|
|
130
|
+
self.ws.append_unique(STAGE_FILES["subs"], [domain])
|
|
131
|
+
|
|
132
|
+
with ThreadPoolExecutor(max_workers=self.threads) as pool:
|
|
133
|
+
for wave in WAVES:
|
|
134
|
+
active = [s for s in wave if s in wanted]
|
|
135
|
+
if active:
|
|
136
|
+
self._run_wave(active, domain, pool, resume, summary)
|
|
137
|
+
return summary
|
|
138
|
+
|
|
139
|
+
# ---- one dependency wave --------------------------------------------
|
|
140
|
+
def _run_wave(
|
|
141
|
+
self,
|
|
142
|
+
wave: list[str],
|
|
143
|
+
domain: str,
|
|
144
|
+
pool: ThreadPoolExecutor,
|
|
145
|
+
resume: bool,
|
|
146
|
+
summary: ReconSummary,
|
|
147
|
+
) -> None:
|
|
148
|
+
tasks: list[tuple[str, ToolPlugin, PluginContext]] = []
|
|
149
|
+
to_run: list[str] = []
|
|
150
|
+
|
|
151
|
+
for stage in wave:
|
|
152
|
+
if resume and self.ws.state.is_done(stage):
|
|
153
|
+
self._emit({"event": "stage_resumed", "stage": stage})
|
|
154
|
+
summary.stages.append(
|
|
155
|
+
StageOutcome(
|
|
156
|
+
stage=stage,
|
|
157
|
+
total=self.ws.count(STAGE_FILES[stage]),
|
|
158
|
+
resumed=True,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
continue
|
|
162
|
+
to_run.append(stage)
|
|
163
|
+
self._emit({"event": "stage_start", "stage": stage})
|
|
164
|
+
self.ws.state.start(stage)
|
|
165
|
+
for name in STAGE_PLUGINS[stage]:
|
|
166
|
+
plugin = self.registry.get(name)
|
|
167
|
+
if plugin is None:
|
|
168
|
+
continue
|
|
169
|
+
tasks.append((stage, plugin, self._context_for(stage, domain, plugin)))
|
|
170
|
+
|
|
171
|
+
# run every plugin in the wave concurrently; collect by stage
|
|
172
|
+
results: dict[str, list[tuple[str, PluginResult]]] = defaultdict(list)
|
|
173
|
+
futures = {pool.submit(p.execute, ctx): (stage, p.name) for stage, p, ctx in tasks}
|
|
174
|
+
for fut in as_completed(futures):
|
|
175
|
+
stage, name = futures[fut]
|
|
176
|
+
try:
|
|
177
|
+
result = fut.result()
|
|
178
|
+
except Exception as exc: # a crashing tool must not sink the run
|
|
179
|
+
result = PluginResult(
|
|
180
|
+
name, Capability.SUBDOMAIN, skipped=True, reason=f"error: {exc}"
|
|
181
|
+
)
|
|
182
|
+
results[stage].append((name, result))
|
|
183
|
+
if result.skipped:
|
|
184
|
+
self._emit(
|
|
185
|
+
{
|
|
186
|
+
"event": "plugin_skip",
|
|
187
|
+
"stage": stage,
|
|
188
|
+
"plugin": name,
|
|
189
|
+
"reason": result.reason,
|
|
190
|
+
}
|
|
191
|
+
)
|
|
192
|
+
else:
|
|
193
|
+
self._emit(
|
|
194
|
+
{
|
|
195
|
+
"event": "plugin_done",
|
|
196
|
+
"stage": stage,
|
|
197
|
+
"plugin": name,
|
|
198
|
+
"found": len(result.items),
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# merge + record each stage on the main thread (no write races)
|
|
203
|
+
for stage in to_run:
|
|
204
|
+
summary.stages.append(self._finalize_stage(stage, results.get(stage, [])))
|
|
205
|
+
|
|
206
|
+
# ---- merge one stage's results --------------------------------------
|
|
207
|
+
def _finalize_stage(self, stage: str, results: list[tuple[str, PluginResult]]) -> StageOutcome:
|
|
208
|
+
outcome = StageOutcome(stage=stage)
|
|
209
|
+
merged: list[str] = []
|
|
210
|
+
for name, result in results:
|
|
211
|
+
if result.skipped:
|
|
212
|
+
outcome.skipped.append((name, result.reason))
|
|
213
|
+
continue
|
|
214
|
+
outcome.ran.append(name)
|
|
215
|
+
merged.extend(self._scope_filter(stage, result.items))
|
|
216
|
+
|
|
217
|
+
outcome.new = self.ws.append_unique(STAGE_FILES[stage], merged)
|
|
218
|
+
|
|
219
|
+
# if nothing resolved (no dnsx, or it returned nothing), fall back to
|
|
220
|
+
# the raw subdomains so live/ports still have something to work on
|
|
221
|
+
fallback = False
|
|
222
|
+
if stage == "resolve" and self.ws.count(STAGE_FILES["resolve"]) == 0:
|
|
223
|
+
self.ws.append_unique(STAGE_FILES["resolve"], self.ws.read_lines(STAGE_FILES["subs"]))
|
|
224
|
+
fallback = True
|
|
225
|
+
|
|
226
|
+
outcome.total = self.ws.count(STAGE_FILES[stage])
|
|
227
|
+
self.ws.state.set_count(STAGE_COUNTS[stage], outcome.total)
|
|
228
|
+
if outcome.ran or fallback:
|
|
229
|
+
self.ws.state.done(stage, new=outcome.new, total=outcome.total, fallback=fallback)
|
|
230
|
+
else:
|
|
231
|
+
self.ws.state.skip(stage)
|
|
232
|
+
self._emit(
|
|
233
|
+
{
|
|
234
|
+
"event": "stage_done",
|
|
235
|
+
"stage": stage,
|
|
236
|
+
"new": outcome.new,
|
|
237
|
+
"total": outcome.total,
|
|
238
|
+
"ran": outcome.ran,
|
|
239
|
+
"skipped": outcome.skipped,
|
|
240
|
+
}
|
|
241
|
+
)
|
|
242
|
+
return outcome
|
|
243
|
+
|
|
244
|
+
# ---- wiring inputs per stage ----------------------------------------
|
|
245
|
+
def _context_for(self, stage: str, domain: str, plugin) -> PluginContext:
|
|
246
|
+
"""Feed each plugin the right input for its stage."""
|
|
247
|
+
target: Optional[str] = None
|
|
248
|
+
inputs: list[str] = []
|
|
249
|
+
if stage == "subs":
|
|
250
|
+
target = domain
|
|
251
|
+
elif stage == "resolve":
|
|
252
|
+
inputs = self.ws.read_lines(STAGE_FILES["subs"])
|
|
253
|
+
elif stage in ("live", "ports"):
|
|
254
|
+
inputs = self.ws.read_lines(STAGE_FILES["resolve"])
|
|
255
|
+
elif stage == "urls":
|
|
256
|
+
if plugin.name in _SEED_URL_PLUGINS:
|
|
257
|
+
target = domain
|
|
258
|
+
else: # crawlers walk the live hosts
|
|
259
|
+
inputs = self.ws.read_lines(STAGE_FILES["live"])
|
|
260
|
+
return PluginContext(
|
|
261
|
+
config=self.config,
|
|
262
|
+
runner=self.runner,
|
|
263
|
+
target=target,
|
|
264
|
+
inputs=inputs,
|
|
265
|
+
extra={"use_cache": self.use_cache},
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
def _scope_filter(self, stage: str, items: list[str]) -> list[str]:
|
|
269
|
+
"""Keep discovered subdomains inside scope; pass other stages through.
|
|
270
|
+
|
|
271
|
+
Later stages derive from the already-filtered subdomain set, so only
|
|
272
|
+
discovery needs a scope check — defence in depth so a noisy source can
|
|
273
|
+
never write an out-of-scope host to disk.
|
|
274
|
+
"""
|
|
275
|
+
if stage != "subs":
|
|
276
|
+
return items
|
|
277
|
+
return self.ws.filter_scope(items)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def run_recon(
|
|
281
|
+
ws: Workspace,
|
|
282
|
+
config: Config,
|
|
283
|
+
domain: str,
|
|
284
|
+
*,
|
|
285
|
+
stages: Optional[list[str]] = None,
|
|
286
|
+
resume: bool = True,
|
|
287
|
+
runner: Optional[CommandRunner] = None,
|
|
288
|
+
registry: Optional[PluginRegistry] = None,
|
|
289
|
+
on_event: Optional[EventFn] = None,
|
|
290
|
+
threads: Optional[int] = None,
|
|
291
|
+
use_cache: bool = False,
|
|
292
|
+
) -> ReconSummary:
|
|
293
|
+
"""Convenience wrapper: build a :class:`Pipeline` and run it once."""
|
|
294
|
+
return Pipeline(
|
|
295
|
+
ws,
|
|
296
|
+
config,
|
|
297
|
+
runner=runner,
|
|
298
|
+
registry=registry,
|
|
299
|
+
on_event=on_event,
|
|
300
|
+
threads=threads,
|
|
301
|
+
use_cache=use_cache,
|
|
302
|
+
).run(domain, stages, resume=resume)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""HuntKit plugin system.
|
|
2
|
+
|
|
3
|
+
Each external tool is a self-contained :class:`ToolPlugin` in its own module.
|
|
4
|
+
The :func:`get_registry` singleton discovers them automatically, so extending
|
|
5
|
+
HuntKit is: write one file, and it is available everywhere.
|
|
6
|
+
|
|
7
|
+
from huntkit.plugins import get_registry
|
|
8
|
+
reg = get_registry()
|
|
9
|
+
reg.get("subfinder") # a plugin by name
|
|
10
|
+
reg.by_category(Category.SCAN) # all vuln/fuzz plugins
|
|
11
|
+
reg.available(runner) # what is actually installed
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from .base import (
|
|
17
|
+
Capability,
|
|
18
|
+
Category,
|
|
19
|
+
InputMode,
|
|
20
|
+
PluginContext,
|
|
21
|
+
PluginResult,
|
|
22
|
+
ToolPlugin,
|
|
23
|
+
)
|
|
24
|
+
from .registry import PluginRegistry, discover, get_registry
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Capability",
|
|
28
|
+
"Category",
|
|
29
|
+
"InputMode",
|
|
30
|
+
"PluginContext",
|
|
31
|
+
"PluginResult",
|
|
32
|
+
"ToolPlugin",
|
|
33
|
+
"PluginRegistry",
|
|
34
|
+
"discover",
|
|
35
|
+
"get_registry",
|
|
36
|
+
]
|
huntkit/plugins/amass.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""amass — in-depth DNS/asset enumeration (OWASP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ..utils import validators as v
|
|
6
|
+
from ..utils.process import ProcResult
|
|
7
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Amass(ToolPlugin):
|
|
11
|
+
name = "amass"
|
|
12
|
+
category = Category.DISCOVERY
|
|
13
|
+
description = "In-depth DNS/asset enumeration"
|
|
14
|
+
install = "sudo apt install amass # or go install ...owasp-amass/amass/v4/...@master"
|
|
15
|
+
consumes = Capability.DOMAIN
|
|
16
|
+
produces = Capability.SUBDOMAIN
|
|
17
|
+
input_mode = InputMode.TARGET
|
|
18
|
+
|
|
19
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
20
|
+
# passive keeps it OSINT-only (no active DNS traffic to the target)
|
|
21
|
+
return ["enum", "-passive", "-nocolor", "-d", str(ctx.target)]
|
|
22
|
+
|
|
23
|
+
def parse(self, result: ProcResult, ctx: PluginContext) -> list[str]:
|
|
24
|
+
# amass interleaves progress/banner text; keep only real names
|
|
25
|
+
return [ln for ln in result.lines if v.is_domain(ln)]
|
huntkit/plugins/arjun.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""arjun — HTTP parameter discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Arjun(ToolPlugin):
|
|
9
|
+
name = "arjun"
|
|
10
|
+
category = Category.SCAN
|
|
11
|
+
description = "HTTP parameter discovery"
|
|
12
|
+
install = "pipx install arjun # or pip install arjun"
|
|
13
|
+
consumes = Capability.URL
|
|
14
|
+
produces = Capability.PARAM
|
|
15
|
+
input_mode = InputMode.TARGET
|
|
16
|
+
|
|
17
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
18
|
+
return ["-u", str(ctx.target), "-q"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""assetfinder — passive subdomain discovery (tomnomnom)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Assetfinder(ToolPlugin):
|
|
9
|
+
name = "assetfinder"
|
|
10
|
+
category = Category.DISCOVERY
|
|
11
|
+
description = "Passive subdomain discovery"
|
|
12
|
+
install = "go install github.com/tomnomnom/assetfinder@latest"
|
|
13
|
+
consumes = Capability.DOMAIN
|
|
14
|
+
produces = Capability.SUBDOMAIN
|
|
15
|
+
input_mode = InputMode.TARGET
|
|
16
|
+
|
|
17
|
+
def build_args(self, ctx: PluginContext) -> list[str]:
|
|
18
|
+
return ["--subs-only", str(ctx.target)]
|