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.
Files changed (86) hide show
  1. huntkit/__init__.py +4 -0
  2. huntkit/__main__.py +8 -0
  3. huntkit/ai/__init__.py +42 -0
  4. huntkit/ai/advisor.py +133 -0
  5. huntkit/ai/providers.py +200 -0
  6. huntkit/app/__init__.py +13 -0
  7. huntkit/app/config_cmd.py +65 -0
  8. huntkit/app/context.py +50 -0
  9. huntkit/app/main.py +1166 -0
  10. huntkit/app/plugins_cmd.py +79 -0
  11. huntkit/app/workspace_cmd.py +62 -0
  12. huntkit/core/__init__.py +6 -0
  13. huntkit/core/cache.py +67 -0
  14. huntkit/core/config.py +272 -0
  15. huntkit/core/logger.py +66 -0
  16. huntkit/core/pacing.py +71 -0
  17. huntkit/core/runner.py +117 -0
  18. huntkit/core/state.py +154 -0
  19. huntkit/core/workspace.py +145 -0
  20. huntkit/enrich/__init__.py +39 -0
  21. huntkit/enrich/asn.py +139 -0
  22. huntkit/enrich/engine.py +167 -0
  23. huntkit/enrich/favicon.py +91 -0
  24. huntkit/enrich/mmh3.py +69 -0
  25. huntkit/fingerprint/__init__.py +26 -0
  26. huntkit/fingerprint/engine.py +83 -0
  27. huntkit/fingerprint/rules.py +170 -0
  28. huntkit/history/__init__.py +38 -0
  29. huntkit/history/diff.py +95 -0
  30. huntkit/history/store.py +133 -0
  31. huntkit/intel/__init__.py +48 -0
  32. huntkit/intel/engine.py +233 -0
  33. huntkit/intel/signals.py +826 -0
  34. huntkit/knowledge/__init__.py +27 -0
  35. huntkit/knowledge/playbooks.py +452 -0
  36. huntkit/monitor/__init__.py +24 -0
  37. huntkit/monitor/runner.py +111 -0
  38. huntkit/notify/__init__.py +16 -0
  39. huntkit/notify/webhook.py +73 -0
  40. huntkit/pipeline.py +302 -0
  41. huntkit/plugins/__init__.py +36 -0
  42. huntkit/plugins/amass.py +25 -0
  43. huntkit/plugins/arjun.py +18 -0
  44. huntkit/plugins/assetfinder.py +18 -0
  45. huntkit/plugins/base.py +220 -0
  46. huntkit/plugins/chaos.py +20 -0
  47. huntkit/plugins/dalfox.py +24 -0
  48. huntkit/plugins/dnsx.py +19 -0
  49. huntkit/plugins/ffuf.py +34 -0
  50. huntkit/plugins/findomain.py +18 -0
  51. huntkit/plugins/gau.py +18 -0
  52. huntkit/plugins/hakrawler.py +19 -0
  53. huntkit/plugins/httpx.py +30 -0
  54. huntkit/plugins/katana.py +23 -0
  55. huntkit/plugins/naabu.py +22 -0
  56. huntkit/plugins/nmap.py +39 -0
  57. huntkit/plugins/nuclei.py +27 -0
  58. huntkit/plugins/registry.py +117 -0
  59. huntkit/plugins/subfinder.py +21 -0
  60. huntkit/plugins/waybackurls.py +29 -0
  61. huntkit/plugins/whatweb.py +19 -0
  62. huntkit/py.typed +0 -0
  63. huntkit/report/__init__.py +103 -0
  64. huntkit/report/builder.py +145 -0
  65. huntkit/report/render.py +323 -0
  66. huntkit/scanner/__init__.py +28 -0
  67. huntkit/scanner/runner.py +239 -0
  68. huntkit/secrets/__init__.py +34 -0
  69. huntkit/secrets/patterns.py +172 -0
  70. huntkit/secrets/scanner.py +133 -0
  71. huntkit/shots/__init__.py +29 -0
  72. huntkit/shots/runner.py +186 -0
  73. huntkit/tui/__init__.py +13 -0
  74. huntkit/tui/dashboard.py +160 -0
  75. huntkit/utils/__init__.py +6 -0
  76. huntkit/utils/filesystem.py +109 -0
  77. huntkit/utils/process.py +104 -0
  78. huntkit/utils/severity.py +49 -0
  79. huntkit/utils/terminal.py +88 -0
  80. huntkit/utils/validators.py +99 -0
  81. huntkit-0.3.0.dist-info/METADATA +340 -0
  82. huntkit-0.3.0.dist-info/RECORD +86 -0
  83. huntkit-0.3.0.dist-info/WHEEL +5 -0
  84. huntkit-0.3.0.dist-info/entry_points.txt +2 -0
  85. huntkit-0.3.0.dist-info/licenses/LICENSE +21 -0
  86. 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
+ ]
@@ -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)]
@@ -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)]