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,220 @@
1
+ """The plugin contract.
2
+
3
+ Every external tool HuntKit drives is a :class:`ToolPlugin`. A plugin is a
4
+ small, declarative description of *one* binary:
5
+
6
+ - metadata name / category / install hint / description
7
+ - typing what it consumes and what it produces (a :class:`Capability`)
8
+ - ``build_args`` turn a run context into the argv that follows the binary
9
+ - ``parse`` turn the process output into a list of items
10
+
11
+ Everything hard — binary resolution, per-tool timeouts and extra args, proxy
12
+ environment, retries, caching — already lives in :class:`~huntkit.core.runner.
13
+ CommandRunner`. A plugin never touches subprocess; it only says *what* to run
14
+ and *how to read the result*. That is what makes adding a new tool a matter of
15
+ dropping one file into this package: the registry discovers it, the pipeline
16
+ schedules it by capability, and nothing else changes.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from abc import ABC, abstractmethod
22
+ from dataclasses import dataclass, field
23
+ from enum import Enum
24
+ from typing import TYPE_CHECKING, Optional
25
+
26
+ from ..utils.process import ProcResult
27
+
28
+ if TYPE_CHECKING: # avoid import cycle at runtime; only needed for typing
29
+ from ..core.config import Config
30
+ from ..core.runner import CommandRunner
31
+
32
+
33
+ class Capability(str, Enum):
34
+ """The data types that flow between plugins in the recon pipeline.
35
+
36
+ A plugin ``consumes`` one type and ``produces`` another; the pipeline
37
+ chains plugins by matching output to input (subdomains -> hosts -> urls
38
+ -> findings) without any plugin knowing about the others.
39
+ """
40
+
41
+ DOMAIN = "domain" # a seed root domain (example.com)
42
+ SUBDOMAIN = "subdomain" # a discovered name (api.example.com)
43
+ HOST = "host" # a name that resolves / is probed live
44
+ URL = "url" # a live http(s) endpoint
45
+ PORT = "port" # an open host:port
46
+ PARAM = "param" # a discovered request parameter
47
+ JS = "js" # a javascript asset
48
+ FINDING = "finding" # a vuln / signal worth a human's attention
49
+
50
+
51
+ class Category(str, Enum):
52
+ """Coarse grouping for `doctor`, help output, and pipeline stages."""
53
+
54
+ DISCOVERY = "discovery" # find subdomains / assets
55
+ RESOLVE = "resolve" # probe which are live + fingerprint
56
+ PORTS = "ports" # port / service scanning
57
+ URLS = "urls" # gather / crawl urls
58
+ SCAN = "scan" # vuln / fuzz / param analysis
59
+ ENRICH = "enrich" # metadata (asn, cdn, waf, favicon, ...)
60
+
61
+
62
+ class InputMode(str, Enum):
63
+ """How the target feeds into the tool.
64
+
65
+ Only :data:`STDIN` is auto-wired by the base class; for the others the
66
+ plugin places the target/inputs itself inside :meth:`ToolPlugin.build_args`,
67
+ because flag placement differs per tool (``-d dom`` vs positional vs ``-t``).
68
+ """
69
+
70
+ TARGET = "target" # a single seed placed by build_args (ctx.target)
71
+ STDIN = "stdin" # ctx.inputs piped to the process stdin, newline-joined
72
+ ARGS = "args" # ctx.inputs placed by build_args as argv
73
+ NONE = "none" # takes no external target
74
+
75
+
76
+ @dataclass
77
+ class PluginContext:
78
+ """Everything a plugin needs to build and run one invocation.
79
+
80
+ Deliberately small and decoupled: a plugin sees the config and a runner,
81
+ a seed ``target`` (for discovery) and/or a list of ``inputs`` (for tools
82
+ that consume the previous stage's output). ``extra`` is an escape hatch
83
+ for one-off knobs (e.g. a FUZZ url) without growing this class.
84
+ """
85
+
86
+ config: Config
87
+ runner: CommandRunner
88
+ target: Optional[str] = None
89
+ inputs: list[str] = field(default_factory=list)
90
+ extra: dict = field(default_factory=dict)
91
+
92
+
93
+ @dataclass
94
+ class PluginResult:
95
+ """Outcome of a single plugin run."""
96
+
97
+ plugin: str
98
+ produces: Capability
99
+ items: list[str] = field(default_factory=list)
100
+ raw: Optional[ProcResult] = None
101
+ ok: bool = False
102
+ skipped: bool = False # tool not installed / no input — not a failure
103
+ reason: str = ""
104
+
105
+ @property
106
+ def count(self) -> int:
107
+ return len(self.items)
108
+
109
+ def __bool__(self) -> bool:
110
+ return self.ok and not self.skipped
111
+
112
+
113
+ class ToolPlugin(ABC):
114
+ """Base class for every tool integration.
115
+
116
+ Subclasses set the class attributes and implement :meth:`build_args`.
117
+ Override :meth:`parse` when the tool's output is not one item per line.
118
+ The registry instantiates each concrete subclass once (they are stateless).
119
+ """
120
+
121
+ # ---- metadata (override as class attributes) -------------------------
122
+ name: str = "" # registry key; usually the binary name
123
+ binary: str = "" # executable to resolve (defaults to name)
124
+ category: Category = Category.DISCOVERY
125
+ description: str = ""
126
+ install: str = "" # copy-pasteable install hint
127
+ consumes: Optional[Capability] = None
128
+ produces: Capability = Capability.SUBDOMAIN
129
+ input_mode: InputMode = InputMode.TARGET
130
+ needs_api_key: Optional[str] = None # api_keys entry required, if any
131
+
132
+ # A subclass is registrable only once it declares a name. The abstract
133
+ # base and any intermediate helpers stay out of the registry.
134
+ def __init_subclass__(cls, **kwargs) -> None:
135
+ super().__init_subclass__(**kwargs)
136
+ if not getattr(cls, "binary", "") and getattr(cls, "name", ""): # type: ignore[arg-type]
137
+ cls.binary = cls.name
138
+
139
+ # ---- the two things a plugin must / may define -----------------------
140
+ @abstractmethod
141
+ def build_args(self, ctx: PluginContext) -> list[str]:
142
+ """Return the argv that follows the binary for this invocation.
143
+
144
+ For STDIN tools this is just the flags (input arrives via stdin);
145
+ for TARGET/ARGS tools, place ``ctx.target`` / ``ctx.inputs`` here.
146
+ """
147
+
148
+ def parse(self, result: ProcResult, ctx: PluginContext) -> list[str]:
149
+ """Extract produced items from the output. Default: one per line."""
150
+ return result.lines
151
+
152
+ # ---- optional hooks --------------------------------------------------
153
+ def stdin_payload(self, ctx: PluginContext) -> Optional[str]:
154
+ """What to pipe to stdin. Default: inputs when in STDIN mode."""
155
+ if self.input_mode is InputMode.STDIN and ctx.inputs:
156
+ return "\n".join(ctx.inputs)
157
+ return None
158
+
159
+ def cache_key(self, ctx: PluginContext) -> Optional[str]:
160
+ """Content-addressed cache key, or None to disable caching.
161
+
162
+ Active only when the run opts in (``ctx.extra["use_cache"]``). The key
163
+ binds the binary, its arguments, and the stdin payload, so identical
164
+ invocations hit the cache and any change misses it.
165
+ """
166
+ if not ctx.extra.get("use_cache"):
167
+ return None
168
+ from ..core.cache import make_key
169
+
170
+ payload = self.stdin_payload(ctx) or ""
171
+ return make_key(self.binary, *self.build_args(ctx), payload)
172
+
173
+ def is_available(self, runner: CommandRunner) -> bool:
174
+ return runner.available(self.binary)
175
+
176
+ # ---- orchestration (do not usually override) -------------------------
177
+ def execute(self, ctx: PluginContext) -> PluginResult:
178
+ """Resolve, run, and parse — returning a uniform :class:`PluginResult`.
179
+
180
+ Never raises for the ordinary "tool missing" / "empty output" cases;
181
+ those are surfaced as ``skipped``/empty results so one gap does not
182
+ abort a whole pipeline.
183
+ """
184
+ from ..core.runner import ToolNotFound
185
+
186
+ if self.needs_api_key and not ctx.config.api_keys.get(self.needs_api_key):
187
+ return PluginResult(
188
+ self.name,
189
+ self.produces,
190
+ skipped=True,
191
+ reason=f"missing api key: {self.needs_api_key}",
192
+ )
193
+ if not self.is_available(ctx.runner):
194
+ return PluginResult(self.name, self.produces, skipped=True, reason="not installed")
195
+ if self.input_mode in (InputMode.STDIN, InputMode.ARGS) and not ctx.inputs:
196
+ return PluginResult(self.name, self.produces, skipped=True, reason="no input")
197
+
198
+ args = self.build_args(ctx)
199
+ try:
200
+ result = ctx.runner.run(
201
+ self.binary,
202
+ args,
203
+ stdin_data=self.stdin_payload(ctx),
204
+ cache_key=self.cache_key(ctx),
205
+ )
206
+ except ToolNotFound as exc:
207
+ return PluginResult(self.name, self.produces, skipped=True, reason=str(exc))
208
+
209
+ # Non-zero exit is common (empty source); still parse what came back.
210
+ items = self.parse(result, ctx)
211
+ return PluginResult(
212
+ self.name,
213
+ self.produces,
214
+ items=items,
215
+ raw=result,
216
+ ok=result.ok,
217
+ )
218
+
219
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
220
+ return f"<{type(self).__name__} {self.name} {self.category.value}>"
@@ -0,0 +1,20 @@
1
+ """chaos — ProjectDiscovery's Chaos subdomain dataset (needs an API key)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
6
+
7
+
8
+ class Chaos(ToolPlugin):
9
+ name = "chaos"
10
+ category = Category.DISCOVERY
11
+ description = "Query the ProjectDiscovery Chaos dataset"
12
+ install = "go install github.com/projectdiscovery/chaos-client/cmd/chaos@latest"
13
+ consumes = Capability.DOMAIN
14
+ produces = Capability.SUBDOMAIN
15
+ input_mode = InputMode.TARGET
16
+ needs_api_key = "chaos"
17
+
18
+ def build_args(self, ctx: PluginContext) -> list[str]:
19
+ key = ctx.config.api_keys.get("chaos", "")
20
+ return ["-d", str(ctx.target), "-silent", "-key", key]
@@ -0,0 +1,24 @@
1
+ """dalfox — XSS scanning / parameter analysis (hahwul)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core.pacing import Pace, delay_arg
6
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
7
+
8
+
9
+ class Dalfox(ToolPlugin):
10
+ name = "dalfox"
11
+ category = Category.SCAN
12
+ description = "XSS scanning / parameter analysis"
13
+ install = "go install github.com/hahwul/dalfox/v2@latest"
14
+ consumes = Capability.URL
15
+ produces = Capability.FINDING
16
+ input_mode = InputMode.STDIN
17
+
18
+ def build_args(self, ctx: PluginContext) -> list[str]:
19
+ # pipe mode reads candidate urls from stdin
20
+ pace = Pace.from_config(ctx.config)
21
+ args = ["pipe", "--silence", "--no-color", "--no-spinner"]
22
+ args += ["--worker", str(pace.concurrency)]
23
+ args += delay_arg("--delay", pace.delay, unit="ms") # dalfox wants ms
24
+ return args
@@ -0,0 +1,19 @@
1
+ """dnsx — fast DNS resolver; filters a name list down to what resolves."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core.pacing import Pace, rate_arg
6
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
7
+
8
+
9
+ class Dnsx(ToolPlugin):
10
+ name = "dnsx"
11
+ category = Category.RESOLVE
12
+ description = "Resolve a subdomain list, keeping only live DNS names"
13
+ install = "go install github.com/projectdiscovery/dnsx/cmd/dnsx@latest"
14
+ consumes = Capability.SUBDOMAIN
15
+ produces = Capability.HOST
16
+ input_mode = InputMode.STDIN
17
+
18
+ def build_args(self, ctx: PluginContext) -> list[str]:
19
+ return ["-silent", "-nc", *rate_arg("-rate-limit", Pace.from_config(ctx.config).rate)]
@@ -0,0 +1,34 @@
1
+ """ffuf — content/vhost/parameter fuzzing.
2
+
3
+ Needs a URL containing the ``FUZZ`` marker and a wordlist. If the target has
4
+ no marker, ``/FUZZ`` is appended; the wordlist comes from ``ctx.extra`` or
5
+ ``wordlists.dir_fuzz`` in config.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..core.pacing import Pace, delay_arg, rate_arg
11
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
12
+
13
+
14
+ class Ffuf(ToolPlugin):
15
+ name = "ffuf"
16
+ category = Category.SCAN
17
+ description = "Content/vhost/parameter fuzzing"
18
+ install = "go install github.com/ffuf/ffuf/v2@latest"
19
+ consumes = Capability.URL
20
+ produces = Capability.URL
21
+ input_mode = InputMode.TARGET
22
+
23
+ def build_args(self, ctx: PluginContext) -> list[str]:
24
+ url = str(ctx.target or "")
25
+ if "FUZZ" not in url:
26
+ url = url.rstrip("/") + "/FUZZ"
27
+ args = ["-u", url, "-s", "-mc", "200,204,301,302,307,401,403"]
28
+ wordlist = ctx.extra.get("wordlist") or ctx.config.wordlists.dir_fuzz
29
+ if wordlist:
30
+ args += ["-w", str(wordlist)]
31
+ pace = Pace.from_config(ctx.config)
32
+ args += rate_arg("-rate", pace.rate) # ffuf: total requests/sec
33
+ args += delay_arg("-p", pace.delay, unit="sec") # seconds between requests
34
+ return args
@@ -0,0 +1,18 @@
1
+ """findomain — fast passive subdomain enumeration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
6
+
7
+
8
+ class Findomain(ToolPlugin):
9
+ name = "findomain"
10
+ category = Category.DISCOVERY
11
+ description = "Fast passive subdomain enumeration"
12
+ install = "sudo apt install findomain # or grab a release binary"
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 ["-t", str(ctx.target), "-q"]
huntkit/plugins/gau.py ADDED
@@ -0,0 +1,18 @@
1
+ """gau — fetch known URLs from wayback/otx/commoncrawl (lc/gau)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
6
+
7
+
8
+ class Gau(ToolPlugin):
9
+ name = "gau"
10
+ category = Category.URLS
11
+ description = "Fetch known URLs (wayback/otx/commoncrawl)"
12
+ install = "go install github.com/lc/gau/v2/cmd/gau@latest"
13
+ consumes = Capability.DOMAIN
14
+ produces = Capability.URL
15
+ input_mode = InputMode.TARGET
16
+
17
+ def build_args(self, ctx: PluginContext) -> list[str]:
18
+ return ["--threads", str(ctx.config.general.threads), str(ctx.target)]
@@ -0,0 +1,19 @@
1
+ """hakrawler — fast endpoint crawler that reads live urls from stdin."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
6
+
7
+
8
+ class Hakrawler(ToolPlugin):
9
+ name = "hakrawler"
10
+ category = Category.URLS
11
+ description = "Crawl live hosts for endpoints and assets"
12
+ install = "go install github.com/hakluke/hakrawler@latest"
13
+ consumes = Capability.URL
14
+ produces = Capability.URL
15
+ input_mode = InputMode.STDIN
16
+
17
+ def build_args(self, ctx: PluginContext) -> list[str]:
18
+ # -u unique, -d crawl depth
19
+ return ["-u", "-d", "2"]
@@ -0,0 +1,30 @@
1
+ """httpx — probe live hosts, titles, tech, status codes (ProjectDiscovery).
2
+
3
+ Note: on a bug-bounty box this is ProjectDiscovery's ``httpx``. A Python
4
+ package of the same name ships a different ``httpx`` CLI; pin
5
+ ``tools.httpx.path`` in config if both are installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from ..core.pacing import Pace, delay_arg, rate_arg
11
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
12
+
13
+
14
+ class Httpx(ToolPlugin):
15
+ name = "httpx"
16
+ category = Category.RESOLVE
17
+ description = "Probe live hosts (status, title, tech-detect)"
18
+ install = "go install github.com/projectdiscovery/httpx/cmd/httpx@latest"
19
+ consumes = Capability.SUBDOMAIN
20
+ produces = Capability.URL
21
+ input_mode = InputMode.STDIN
22
+
23
+ def build_args(self, ctx: PluginContext) -> list[str]:
24
+ pace = Pace.from_config(ctx.config)
25
+ args = ["-silent", "-no-color", "-t", str(ctx.config.general.threads)]
26
+ args += rate_arg("-rate-limit", pace.rate)
27
+ args += delay_arg("-delay", pace.delay)
28
+ if ctx.config.http.follow_redirects:
29
+ args.append("-follow-redirects")
30
+ return args
@@ -0,0 +1,23 @@
1
+ """katana — active crawler / URL discovery (ProjectDiscovery)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core.pacing import Pace, rate_arg
6
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
7
+
8
+
9
+ class Katana(ToolPlugin):
10
+ name = "katana"
11
+ category = Category.URLS
12
+ description = "Active crawler / URL discovery"
13
+ install = "go install github.com/projectdiscovery/katana/cmd/katana@latest"
14
+ consumes = Capability.URL
15
+ produces = Capability.URL
16
+ input_mode = InputMode.STDIN
17
+
18
+ def build_args(self, ctx: PluginContext) -> list[str]:
19
+ # urls arrive via stdin; -d = crawl depth, -c = concurrency
20
+ pace = Pace.from_config(ctx.config)
21
+ args = ["-silent", "-nc", "-d", "2", "-c", str(ctx.config.general.threads)]
22
+ args += rate_arg("-rate-limit", pace.rate)
23
+ return args
@@ -0,0 +1,22 @@
1
+ """naabu — fast SYN/CONNECT port scanner (ProjectDiscovery)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core.pacing import Pace, rate_arg
6
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
7
+
8
+
9
+ class Naabu(ToolPlugin):
10
+ name = "naabu"
11
+ category = Category.PORTS
12
+ description = "Fast port scanner"
13
+ install = "go install github.com/projectdiscovery/naabu/v2/cmd/naabu@latest"
14
+ consumes = Capability.HOST
15
+ produces = Capability.PORT
16
+ input_mode = InputMode.STDIN
17
+
18
+ def build_args(self, ctx: PluginContext) -> list[str]:
19
+ # hosts arrive via stdin; emit host:port on stdout. port_rate is a
20
+ # packet budget, a different knob from the HTTP request rate.
21
+ pace = Pace.from_config(ctx.config)
22
+ return ["-silent", "-no-color", *rate_arg("-rate", pace.port_rate)]
@@ -0,0 +1,39 @@
1
+ """nmap — port scan + service/version detection.
2
+
3
+ Reads targets as argv (nmap has no stdin list mode) and emits greppable
4
+ output on stdout, which we parse into ``host:port`` entries.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ..utils.process import ProcResult
10
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
11
+
12
+
13
+ class Nmap(ToolPlugin):
14
+ name = "nmap"
15
+ category = Category.PORTS
16
+ description = "Port scan + service/version detection"
17
+ install = "sudo apt install nmap"
18
+ consumes = Capability.HOST
19
+ produces = Capability.PORT
20
+ input_mode = InputMode.ARGS
21
+
22
+ def build_args(self, ctx: PluginContext) -> list[str]:
23
+ return ["-T4", "-Pn", "--top-ports", "1000", "-oG", "-", *ctx.inputs]
24
+
25
+ def parse(self, result: ProcResult, ctx: PluginContext) -> list[str]:
26
+ out: list[str] = []
27
+ for line in result.lines:
28
+ if "Ports:" not in line:
29
+ continue
30
+ parts = line.split()
31
+ if len(parts) < 2 or parts[0] != "Host:":
32
+ continue
33
+ host = parts[1]
34
+ # e.g. "Ports: 22/open/tcp//ssh///, 80/open/tcp//http///"
35
+ for chunk in line.split("Ports:", 1)[1].split(","):
36
+ fields = chunk.strip().split("/")
37
+ if len(fields) >= 2 and fields[1] == "open":
38
+ out.append(f"{host}:{fields[0]}")
39
+ return out
@@ -0,0 +1,27 @@
1
+ """nuclei — template-based vulnerability scanner (ProjectDiscovery)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core.pacing import Pace, rate_arg
6
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
7
+
8
+
9
+ class Nuclei(ToolPlugin):
10
+ name = "nuclei"
11
+ category = Category.SCAN
12
+ description = "Template-based vulnerability scanner"
13
+ install = "go install github.com/projectdiscovery/nuclei/v3/cmd/nuclei@latest"
14
+ consumes = Capability.URL
15
+ produces = Capability.FINDING
16
+ input_mode = InputMode.STDIN
17
+
18
+ def build_args(self, ctx: PluginContext) -> list[str]:
19
+ n = ctx.config.nuclei
20
+ args = ["-silent", "-nc"]
21
+ if n.severity:
22
+ args += ["-severity", n.severity]
23
+ if n.templates:
24
+ args += ["-t", n.templates]
25
+ args += rate_arg("-rate-limit", Pace.from_config(ctx.config).rate)
26
+ args += n.extra_args
27
+ return args
@@ -0,0 +1,117 @@
1
+ """Plugin discovery and lookup.
2
+
3
+ The registry is the reason a new tool costs one file: on first use it walks
4
+ this package, imports every module, and collects every concrete
5
+ :class:`~huntkit.plugins.base.ToolPlugin` subclass. Nothing has to be listed
6
+ by hand — drop ``huntkit/plugins/mytool.py`` in and it is picked up.
7
+
8
+ Consumers ask by name, category, or capability so the CLI (`doctor`, `plugins`)
9
+ and the recon pipeline can reason about tools generically.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import importlib
15
+ import inspect
16
+ import pkgutil
17
+ from collections.abc import Iterable
18
+ from typing import TYPE_CHECKING, Optional
19
+
20
+ from .base import Capability, Category, ToolPlugin
21
+
22
+ if TYPE_CHECKING:
23
+ from ..core.runner import CommandRunner
24
+
25
+
26
+ class PluginRegistry:
27
+ """An in-memory index of plugin singletons, keyed by name."""
28
+
29
+ def __init__(self) -> None:
30
+ self._by_name: dict[str, ToolPlugin] = {}
31
+
32
+ # ---- population ------------------------------------------------------
33
+ def register(self, plugin: ToolPlugin) -> None:
34
+ if not plugin.name:
35
+ raise ValueError(f"{type(plugin).__name__} has no name")
36
+ if plugin.name in self._by_name:
37
+ raise ValueError(f"duplicate plugin name: {plugin.name!r}")
38
+ self._by_name[plugin.name] = plugin
39
+
40
+ # ---- lookup ----------------------------------------------------------
41
+ def get(self, name: str) -> Optional[ToolPlugin]:
42
+ return self._by_name.get(name)
43
+
44
+ def __contains__(self, name: object) -> bool:
45
+ return name in self._by_name
46
+
47
+ def __len__(self) -> int:
48
+ return len(self._by_name)
49
+
50
+ def all(self) -> list[ToolPlugin]:
51
+ return sorted(self._by_name.values(), key=lambda p: (p.category.value, p.name))
52
+
53
+ def names(self) -> list[str]:
54
+ return sorted(self._by_name)
55
+
56
+ def by_category(self, category: Category) -> list[ToolPlugin]:
57
+ return [p for p in self.all() if p.category is category]
58
+
59
+ def producing(self, cap: Capability) -> list[ToolPlugin]:
60
+ return [p for p in self.all() if p.produces is cap]
61
+
62
+ def consuming(self, cap: Capability) -> list[ToolPlugin]:
63
+ return [p for p in self.all() if p.consumes is cap]
64
+
65
+ def available(self, runner: CommandRunner) -> list[ToolPlugin]:
66
+ """Plugins whose binary is installed / pinned right now."""
67
+ return [p for p in self.all() if p.is_available(runner)]
68
+
69
+ def missing(self, runner: CommandRunner) -> list[ToolPlugin]:
70
+ return [p for p in self.all() if not p.is_available(runner)]
71
+
72
+
73
+ # --------------------------------------------------------------------------
74
+ # Discovery
75
+ # --------------------------------------------------------------------------
76
+ def _iter_plugin_classes(package_name: str) -> Iterable[type[ToolPlugin]]:
77
+ """Import every module in the package and yield concrete plugin classes."""
78
+ package = importlib.import_module(package_name)
79
+ for mod in pkgutil.iter_modules(package.__path__):
80
+ if mod.name in {"base", "registry"} or mod.name.startswith("_"):
81
+ continue
82
+ importlib.import_module(f"{package_name}.{mod.name}")
83
+
84
+ seen: set[type[ToolPlugin]] = set()
85
+ stack: list[type[ToolPlugin]] = list(ToolPlugin.__subclasses__())
86
+ while stack:
87
+ cls = stack.pop()
88
+ if cls in seen:
89
+ continue
90
+ seen.add(cls)
91
+ stack.extend(cls.__subclasses__())
92
+ if inspect.isabstract(cls) or not getattr(cls, "name", ""):
93
+ continue
94
+ # only plugins that actually live in this package — never a stray
95
+ # subclass defined by tests or downstream code
96
+ if not cls.__module__.startswith(package_name):
97
+ continue
98
+ yield cls
99
+
100
+
101
+ def discover(package_name: str = __package__ or "huntkit.plugins") -> PluginRegistry:
102
+ """Build a fresh registry by scanning ``package_name`` for plugins."""
103
+ registry = PluginRegistry()
104
+ for cls in _iter_plugin_classes(package_name):
105
+ registry.register(cls())
106
+ return registry
107
+
108
+
109
+ _REGISTRY: Optional[PluginRegistry] = None
110
+
111
+
112
+ def get_registry(*, refresh: bool = False) -> PluginRegistry:
113
+ """Return the process-wide registry, discovering plugins on first use."""
114
+ global _REGISTRY
115
+ if _REGISTRY is None or refresh:
116
+ _REGISTRY = discover()
117
+ return _REGISTRY
@@ -0,0 +1,21 @@
1
+ """subfinder — passive subdomain enumeration (ProjectDiscovery)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..core.pacing import Pace, rate_arg
6
+ from .base import Capability, Category, InputMode, PluginContext, ToolPlugin
7
+
8
+
9
+ class Subfinder(ToolPlugin):
10
+ name = "subfinder"
11
+ category = Category.DISCOVERY
12
+ description = "Passive subdomain enumeration"
13
+ install = "go install github.com/projectdiscovery/subfinder/v2/cmd/subfinder@latest"
14
+ consumes = Capability.DOMAIN
15
+ produces = Capability.SUBDOMAIN
16
+ input_mode = InputMode.TARGET
17
+
18
+ def build_args(self, ctx: PluginContext) -> list[str]:
19
+ args = ["-silent", "-d", str(ctx.target)]
20
+ args += rate_arg("-rate-limit", Pace.from_config(ctx.config).rate)
21
+ return args